Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1221 lines
43 KiB
TypeScript
1221 lines
43 KiB
TypeScript
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import YAML from 'yaml';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { loadFleetRoster } from '../commands/fleet.js';
|
|
import { executeFleetReconcile } from './fleet-reconciler.js';
|
|
import { compareCodePoints } from './deterministic-order.js';
|
|
import { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js';
|
|
import {
|
|
collectShippedFleetMigrationDispositions,
|
|
previewV1ToV2Migration,
|
|
validateShippedFleetMigrationDispositions,
|
|
type V1ToV2MigrationDecisions,
|
|
type V1ToV2MigrationPreview,
|
|
} from './v1-v2-migration.js';
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
const frameworkFleet = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'..',
|
|
'framework',
|
|
'fleet',
|
|
);
|
|
|
|
afterEach(async (): Promise<void> => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((path): Promise<void> => rm(path, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
|
const root = await mkdtemp(join(tmpdir(), 'v1-v2-migration-roles-'));
|
|
temporaryDirectories.push(root);
|
|
const rolesDir = join(root, 'roles');
|
|
const overrideDir = join(root, 'roles.local');
|
|
await mkdir(rolesDir);
|
|
await mkdir(overrideDir);
|
|
for (const className of ['code', 'review', 'orchestrator', 'interaction', 'custom-domain']) {
|
|
await writeFile(join(rolesDir, `${className}.md`), `# ${className}\n`, 'utf8');
|
|
}
|
|
return { rolesDir, overrideDir };
|
|
}
|
|
|
|
const v1Source = `
|
|
version: 1
|
|
transport: tmux
|
|
tmux:
|
|
socket_name: mosaic-v1
|
|
holder_session: _holder
|
|
defaults:
|
|
working_directory: /srv/fleet
|
|
runtimes:
|
|
pi:
|
|
reset_command: /new
|
|
agents:
|
|
- name: coder0
|
|
alias: Coder Zero
|
|
provider: openai
|
|
runtime: pi
|
|
class: implementer
|
|
working_directory: /srv/coder0
|
|
model_hint: legacy/model:high
|
|
reasoning_level: high
|
|
tool_policy: implementer
|
|
persistent_persona: true
|
|
reset_between_tasks: false
|
|
kickstart_template: legacy-template
|
|
- name: remote0
|
|
runtime: pi
|
|
class: reviewer
|
|
host: build.example.test
|
|
ssh: operator@build.example.test
|
|
socket: remote-fleet
|
|
connector:
|
|
kind: discord
|
|
discord:
|
|
channel_id: channel
|
|
`;
|
|
|
|
function decisions(): V1ToV2MigrationDecisions {
|
|
return {
|
|
generation: 2,
|
|
fleetHost: 'fleet.example.test',
|
|
defaultRuntime: 'pi',
|
|
agents: {
|
|
coder0: {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
kickstartTemplate: 'inventory-only',
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function observations(
|
|
observation: { systemd: 'active' | 'inactive'; tmux: 'present' | 'missing' } = {
|
|
systemd: 'inactive',
|
|
tmux: 'missing',
|
|
},
|
|
): Record<string, typeof observation> {
|
|
return { coder0: observation };
|
|
}
|
|
|
|
async function shippedV1PreviewEvidence(): Promise<
|
|
Record<
|
|
string,
|
|
{
|
|
decisions: V1ToV2MigrationDecisions;
|
|
observations: Record<string, { systemd: 'inactive'; tmux: 'missing' }>;
|
|
}
|
|
>
|
|
> {
|
|
const evidence: Record<
|
|
string,
|
|
{
|
|
decisions: V1ToV2MigrationDecisions;
|
|
observations: Record<string, { systemd: 'inactive'; tmux: 'missing' }>;
|
|
}
|
|
> = {};
|
|
const explicitReplacements: Readonly<Record<string, string>> = {
|
|
worker: 'code',
|
|
canary: 'code',
|
|
analyst: 'researcher',
|
|
};
|
|
const aliases: Readonly<Record<string, string>> = {
|
|
implementer: 'code',
|
|
reviewer: 'review',
|
|
'operator-interaction': 'interaction',
|
|
};
|
|
const automatic = new Set(['orchestrator', 'enhancer', ...Object.keys(aliases)]);
|
|
for (const artifact of SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
({ disposition }) => disposition === 'v1-fixture',
|
|
)) {
|
|
const source = await readFile(join(frameworkFleet, artifact.path), 'utf8');
|
|
const raw = YAML.parse(source) as {
|
|
agents: Array<{ name: string; class?: string; tool_policy?: string }>;
|
|
};
|
|
const agents: V1ToV2MigrationDecisions['agents'] = {};
|
|
const lifecycle: Record<string, { systemd: 'inactive'; tmux: 'missing' }> = {};
|
|
for (const agent of raw.agents) {
|
|
const requestedClass = agent.class ?? 'worker';
|
|
const replacement = explicitReplacements[requestedClass];
|
|
const finalClass = replacement ?? aliases[requestedClass] ?? requestedClass;
|
|
agents[agent.name] = {
|
|
provider: 'fixture',
|
|
model: 'fixture-model',
|
|
reasoning: 'low',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
...(!automatic.has(requestedClass)
|
|
? {
|
|
classDisposition: replacement
|
|
? ({ action: 'replace', className: replacement } as const)
|
|
: ({ action: 'preserve' } as const),
|
|
}
|
|
: {}),
|
|
...(agent.tool_policy === undefined
|
|
? { toolPolicyDisposition: { action: 'replace', className: finalClass } as const }
|
|
: {}),
|
|
};
|
|
lifecycle[agent.name] = { systemd: 'inactive', tmux: 'missing' };
|
|
}
|
|
evidence[artifact.path] = {
|
|
decisions: { generation: 2, defaultRuntime: 'pi', agents },
|
|
observations: lifecycle,
|
|
};
|
|
}
|
|
return evidence;
|
|
}
|
|
|
|
describe('v1-to-v2 migration preview', (): void => {
|
|
it('inventories every v1 field without values and compiles only local agents through v2', async () => {
|
|
const dirs = await semanticDirs();
|
|
const preview = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
sourcePath: '/fixtures/roster.yaml',
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
});
|
|
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.blockers).toEqual([]);
|
|
expect(preview.inventory.map(({ path }) => path)).toEqual(
|
|
expect.arrayContaining([
|
|
'version',
|
|
'tmux.socket_name',
|
|
'defaults.working_directory',
|
|
'runtimes.pi.reset_command',
|
|
'agents[0].kickstart_template',
|
|
'agents[1].host',
|
|
'agents[1].ssh',
|
|
'agents[1].socket',
|
|
'connector.discord.channel_id',
|
|
]),
|
|
);
|
|
expect(preview.inventory.every((entry) => !Object.hasOwn(entry, 'value'))).toBe(true);
|
|
expect(preview.inventoryOnlyAgents).toEqual([
|
|
{
|
|
name: 'remote0',
|
|
fields: ['host', 'socket', 'ssh'],
|
|
disposition: 'inventory-only',
|
|
},
|
|
]);
|
|
expect(
|
|
preview.inventory
|
|
.filter(({ path }) => path.startsWith('agents[1]'))
|
|
.every(({ disposition }) => disposition === 'inventory-only'),
|
|
).toBe(true);
|
|
expect(preview.canonicalRoster?.agents).toHaveLength(1);
|
|
expect(preview.canonicalRoster?.agents[0]).toMatchObject({
|
|
name: 'coder0',
|
|
className: 'code',
|
|
toolPolicy: 'code',
|
|
lifecycle: { enabled: true, desiredState: 'stopped' },
|
|
});
|
|
expect(preview.canonicalYaml).toContain('class: code');
|
|
expect(preview.canonicalYaml).not.toContain('remote0');
|
|
expect(preview.evidence.source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(preview.evidence.candidate?.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(preview.evidence.recovery).toEqual({
|
|
executable: false,
|
|
canaryExecuted: false,
|
|
rollbackExecuted: false,
|
|
requiredArtifacts: [
|
|
'v1-roster-backup',
|
|
'environment-backup',
|
|
'observed-lifecycle-evidence',
|
|
'candidate-v2-roster',
|
|
],
|
|
gateOwner: 'FCM-M4-002',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: 'host equal to reviewed fleet host',
|
|
agentFields:
|
|
' host: fleet.example.test\n ssh: operator@fleet.example.test\n socket: mosaic-v1',
|
|
fleetHost: 'fleet.example.test',
|
|
expectedStatus: 'ready',
|
|
expectedInventoryOnly: false,
|
|
expectedBlocker: undefined,
|
|
},
|
|
{
|
|
label: 'host different from reviewed fleet host',
|
|
agentFields:
|
|
' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet',
|
|
fleetHost: 'fleet.example.test',
|
|
expectedStatus: 'ready',
|
|
expectedInventoryOnly: true,
|
|
expectedBlocker: undefined,
|
|
},
|
|
{
|
|
label: 'ssh only',
|
|
agentFields: ' ssh: operator@build.example.test\n socket: mosaic-v1',
|
|
fleetHost: 'fleet.example.test',
|
|
expectedStatus: 'blocked',
|
|
expectedInventoryOnly: false,
|
|
expectedBlocker: 'agent-locality-disposition-required',
|
|
},
|
|
{
|
|
label: 'contradictory host and ssh targets',
|
|
agentFields:
|
|
' host: fleet.example.test\n ssh: operator@other.example.test\n socket: mosaic-v1',
|
|
fleetHost: 'fleet.example.test',
|
|
expectedStatus: 'blocked',
|
|
expectedInventoryOnly: false,
|
|
expectedBlocker: 'agent-locality-disposition-required',
|
|
},
|
|
{
|
|
label: 'host evidence without reviewed fleet host',
|
|
agentFields: ' host: build.example.test\n socket: mosaic-v1',
|
|
fleetHost: undefined,
|
|
expectedStatus: 'blocked',
|
|
expectedInventoryOnly: false,
|
|
expectedBlocker: 'agent-locality-disposition-required',
|
|
},
|
|
])(
|
|
'classifies $label without inferring locality',
|
|
async ({ agentFields, fleetHost, expectedStatus, expectedInventoryOnly, expectedBlocker }) => {
|
|
const source = v1Source.replace(
|
|
' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet',
|
|
agentFields,
|
|
);
|
|
const migrationDecisions = decisions();
|
|
const baseRemoteDecision = {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high' as const,
|
|
enabled: true,
|
|
launchYolo: false,
|
|
toolPolicyDisposition: { action: 'replace' as const, className: 'review' },
|
|
};
|
|
if (!expectedInventoryOnly) migrationDecisions.agents.remote0 = baseRemoteDecision;
|
|
const reviewedDecisions: V1ToV2MigrationDecisions = fleetHost
|
|
? { ...migrationDecisions, fleetHost }
|
|
: { ...migrationDecisions, fleetHost: undefined };
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: reviewedDecisions,
|
|
observations: expectedInventoryOnly
|
|
? observations()
|
|
: {
|
|
...observations(),
|
|
remote0: { systemd: 'inactive', tmux: 'missing' },
|
|
},
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe(expectedStatus);
|
|
expect(preview.inventoryOnlyAgents.some(({ name }) => name === 'remote0')).toBe(
|
|
expectedInventoryOnly,
|
|
);
|
|
if (expectedInventoryOnly) {
|
|
expect(preview.inventory.filter(({ path }) => path.startsWith('agents[1]'))).toSatisfy(
|
|
(entries: Array<{ disposition: string }>) =>
|
|
entries.every(({ disposition }) => disposition === 'inventory-only'),
|
|
);
|
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
|
|
} else {
|
|
expect(
|
|
preview.inventory
|
|
.filter(({ path }) => path.startsWith('agents[1]'))
|
|
.every(({ disposition }) => disposition !== 'inventory-only'),
|
|
).toBe(true);
|
|
}
|
|
if (expectedBlocker) {
|
|
expect(preview.blockers).toContainEqual(
|
|
expect.objectContaining({ code: expectedBlocker, agent: 'remote0' }),
|
|
);
|
|
expect(preview.evidence.observations).toContainEqual({
|
|
agent: 'remote0',
|
|
systemd: 'inactive',
|
|
tmux: 'missing',
|
|
preservedAs: 'stopped',
|
|
});
|
|
} else if (!expectedInventoryOnly) {
|
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual([
|
|
'coder0',
|
|
'remote0',
|
|
]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['socket_name', " socket_name: ''\n holder_session: _holder"],
|
|
['socketName', " socketName: ''\n holder_session: _holder"],
|
|
] as const)(
|
|
'preserves an explicit empty tmux.%s declaration as the literal default server without a decision',
|
|
async (_field, tmuxBlock) => {
|
|
const source = v1Source.replace(
|
|
' socket_name: mosaic-v1\n holder_session: _holder',
|
|
tmuxBlock,
|
|
);
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations({ systemd: 'active', tmux: 'present' }),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.blockers).toEqual([]);
|
|
expect(preview.canonicalRoster?.tmux.socketName).toBe('');
|
|
expect(preview.canonicalYaml).toContain('socket_name: ""');
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['socket_name', " socket_name: ''\n holder_session: _holder"],
|
|
['socketName', " socketName: ''\n holder_session: _holder"],
|
|
] as const)(
|
|
'treats an explicit empty tmux.%s declaration as authoritative and blocks a named decision',
|
|
async (_field, tmuxBlock) => {
|
|
const source = v1Source.replace(
|
|
' socket_name: mosaic-v1\n holder_session: _holder',
|
|
tmuxBlock,
|
|
);
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: { ...decisions(), socketName: 'decision-socket' },
|
|
observations: observations({ systemd: 'active', tmux: 'present' }),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('blocked');
|
|
expect(preview.canonicalRoster).toBeUndefined();
|
|
expect(preview.blockers).toContainEqual(
|
|
expect.objectContaining({
|
|
code: 'socket-name-conflict',
|
|
path: 'decisions.socketName',
|
|
}),
|
|
);
|
|
expect(preview.evidence.observations).toContainEqual({
|
|
agent: 'coder0',
|
|
systemd: 'active',
|
|
tmux: 'present',
|
|
preservedAs: 'running',
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['socket_name', 'mosaic-v1', 'mosaic-v1', 'ready'],
|
|
['socket_name', 'mosaic-v1', 'other-socket', 'blocked'],
|
|
['socketName', 'mosaic-v1', 'mosaic-v1', 'ready'],
|
|
['socketName', 'mosaic-v1', 'other-socket', 'blocked'],
|
|
] as const)(
|
|
'%s named declaration with a %s reviewed decision preserves declared authority',
|
|
async (field, declaredSocket, decisionSocket, expectedStatus) => {
|
|
const source = v1Source.replace(' socket_name: mosaic-v1', ` ${field}: ${declaredSocket}`);
|
|
const migrationDecisions: V1ToV2MigrationDecisions = {
|
|
...decisions(),
|
|
socketName: decisionSocket,
|
|
};
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: migrationDecisions,
|
|
observations: observations({ systemd: 'active', tmux: 'present' }),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe(expectedStatus);
|
|
expect(preview.evidence.observations).toContainEqual({
|
|
agent: 'coder0',
|
|
systemd: 'active',
|
|
tmux: 'present',
|
|
preservedAs: 'running',
|
|
});
|
|
if (expectedStatus === 'ready') {
|
|
expect(preview.canonicalRoster?.tmux.socketName).toBe('mosaic-v1');
|
|
expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe('running');
|
|
} else {
|
|
expect(preview.canonicalRoster).toBeUndefined();
|
|
expect(preview.blockers).toContainEqual(
|
|
expect.objectContaining({
|
|
code: 'socket-name-conflict',
|
|
path: 'decisions.socketName',
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it('migrates a compatible socket-only local agent with reviewed lifecycle evidence', async () => {
|
|
const source = v1Source
|
|
.replace(' host: build.example.test\n', '')
|
|
.replace(' ssh: operator@build.example.test\n', '')
|
|
.replace(' socket: remote-fleet', ' socket: mosaic-v1');
|
|
const migrationDecisions = decisions();
|
|
migrationDecisions.agents.remote0 = {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
toolPolicyDisposition: { action: 'replace', className: 'review' },
|
|
};
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: migrationDecisions,
|
|
observations: {
|
|
...observations(),
|
|
remote0: { systemd: 'inactive', tmux: 'missing' },
|
|
},
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.inventoryOnlyAgents).toEqual([]);
|
|
expect(preview.inventory).toContainEqual({
|
|
path: 'agents[1].socket',
|
|
disposition: 'candidate',
|
|
});
|
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0', 'remote0']);
|
|
expect(preview.evidence.observations).toContainEqual({
|
|
agent: 'remote0',
|
|
systemd: 'inactive',
|
|
tmux: 'missing',
|
|
preservedAs: 'stopped',
|
|
});
|
|
});
|
|
|
|
it('blocks a socket-only local agent that conflicts with the canonical fleet socket', async () => {
|
|
const source = v1Source
|
|
.replace(' host: build.example.test\n', '')
|
|
.replace(' ssh: operator@build.example.test\n', '');
|
|
const migrationDecisions = decisions();
|
|
migrationDecisions.agents.remote0 = {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
toolPolicyDisposition: { action: 'replace', className: 'review' },
|
|
};
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: migrationDecisions,
|
|
observations: {
|
|
...observations(),
|
|
remote0: { systemd: 'active', tmux: 'present' },
|
|
},
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('blocked');
|
|
expect(preview.inventoryOnlyAgents).toEqual([]);
|
|
expect(preview.blockers).toContainEqual(
|
|
expect.objectContaining({
|
|
code: 'agent-socket-disposition-required',
|
|
path: 'agents[1].socket',
|
|
agent: 'remote0',
|
|
}),
|
|
);
|
|
expect(preview.canonicalRoster).toBeUndefined();
|
|
expect(preview.evidence.observations).toContainEqual({
|
|
agent: 'remote0',
|
|
systemd: 'active',
|
|
tmux: 'present',
|
|
preservedAs: 'running',
|
|
});
|
|
});
|
|
|
|
it('matches production v1 reset defaults when a declared runtime config is empty', async () => {
|
|
const source = v1Source.replace(' pi:\n reset_command: /new', ' pi: {}');
|
|
const productionSource = source
|
|
.replace(/ - name: remote0[\s\S]*?connector:/, 'connector:')
|
|
.replace(/connector:[\s\S]*$/, '');
|
|
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-runtime-parity-')), 'roster.yaml');
|
|
temporaryDirectories.push(dirname(rosterPath));
|
|
await writeFile(rosterPath, productionSource);
|
|
const productionRoster = await loadFleetRoster(rosterPath);
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('ready');
|
|
expect(productionRoster.runtimes.pi?.resetCommand).toBe('/clear');
|
|
expect(preview.canonicalRoster?.runtimes.pi?.resetCommand).toBe(
|
|
productionRoster.runtimes.pi?.resetCommand,
|
|
);
|
|
});
|
|
|
|
it('emits byte-identical preview evidence when equivalent local agents are reordered', async () => {
|
|
const coder1 = ` - name: coder1
|
|
alias: Coder One
|
|
provider: openai
|
|
runtime: pi
|
|
class: reviewer
|
|
working_directory: /srv/coder1
|
|
reasoning_level: high
|
|
tool_policy: reviewer
|
|
`;
|
|
const sourceA = v1Source.replace(' - name: remote0', `${coder1} - name: remote0`);
|
|
const sourceB = sourceA.replace(
|
|
/( - name: coder0[\s\S]*?)( - name: coder1[\s\S]*?)( - name: remote0)/,
|
|
'$2$1$3',
|
|
);
|
|
const migrationDecisions = decisions();
|
|
migrationDecisions.agents.coder1 = {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
};
|
|
const lifecycle = {
|
|
...observations(),
|
|
coder1: { systemd: 'active' as const, tmux: 'present' as const },
|
|
};
|
|
const dirs = await semanticDirs();
|
|
const previews = await Promise.all(
|
|
[sourceA, sourceB].map((source) =>
|
|
previewV1ToV2Migration({
|
|
source,
|
|
decisions: migrationDecisions,
|
|
observations: lifecycle,
|
|
personaDirs: dirs,
|
|
}),
|
|
),
|
|
);
|
|
const previewA = previews[0]!;
|
|
const previewB = previews[1]!;
|
|
|
|
expect(previewA.status).toBe('ready');
|
|
expect(previewB.status).toBe('ready');
|
|
expect(previewB.evidence.observations).toEqual(previewA.evidence.observations);
|
|
expect({
|
|
...previewB.evidence,
|
|
source: { ...previewB.evidence.source, sha256: '<source-specific>' },
|
|
}).toEqual({
|
|
...previewA.evidence,
|
|
source: { ...previewA.evidence.source, sha256: '<source-specific>' },
|
|
});
|
|
expect(previewB.canonicalYaml).toBe(previewA.canonicalYaml);
|
|
});
|
|
|
|
it('emits byte-identical preview evidence when equivalent remote agents are reordered', async () => {
|
|
const remote1 = ` - name: remote1
|
|
runtime: pi
|
|
class: reviewer
|
|
host: review.example.test
|
|
ssh: operator@review.example.test
|
|
socket: review-fleet
|
|
`;
|
|
const sourceA = v1Source.replace('connector:', `${remote1}connector:`);
|
|
const sourceB = sourceA.replace(
|
|
/( - name: remote0[\s\S]*?)( - name: remote1[\s\S]*?)(connector:)/,
|
|
'$2$1$3',
|
|
);
|
|
const dirs = await semanticDirs();
|
|
const previews = await Promise.all(
|
|
[sourceA, sourceB].map((source) =>
|
|
previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
}),
|
|
),
|
|
);
|
|
const previewA = previews[0]!;
|
|
const previewB = previews[1]!;
|
|
const semanticOutput = (preview: V1ToV2MigrationPreview): object => ({
|
|
...preview,
|
|
evidence: {
|
|
...preview.evidence,
|
|
source: { ...preview.evidence.source, sha256: '<source-specific>' },
|
|
},
|
|
});
|
|
|
|
expect(previewA.status).toBe('ready');
|
|
expect(previewB.status).toBe('ready');
|
|
expect(semanticOutput(previewB)).toEqual(semanticOutput(previewA));
|
|
});
|
|
|
|
it('emits byte-identical canonical evidence regardless of locale collation', async () => {
|
|
expect(['😀', 'z', '', 'ä'].sort(compareCodePoints)).toEqual(['z', 'ä', '', '😀']);
|
|
const dirs = await semanticDirs();
|
|
const renderUnderLocale = async (locale: string): Promise<string> => {
|
|
const collator = new Intl.Collator(locale);
|
|
const localeCompare = vi
|
|
.spyOn(String.prototype, 'localeCompare')
|
|
.mockImplementation(function (this: string, other: string): number {
|
|
return collator.compare(this, other);
|
|
});
|
|
try {
|
|
const preview = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
});
|
|
return JSON.stringify(preview);
|
|
} finally {
|
|
localeCompare.mockRestore();
|
|
}
|
|
};
|
|
|
|
expect(await renderUnderLocale('en')).toBe(await renderUnderLocale('sv'));
|
|
});
|
|
|
|
it.each([
|
|
[{ systemd: 'active' as const, tmux: 'present' as const }, 'running'],
|
|
[{ systemd: 'inactive' as const, tmux: 'missing' as const }, 'stopped'],
|
|
])('preserves an unambiguous observed lifecycle %j as %s', async (observation, expected) => {
|
|
const preview = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
sourcePath: '/fixtures/roster.yaml',
|
|
decisions: decisions(),
|
|
observations: observations(observation),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(preview.canonicalRoster?.agents[0]?.lifecycle.desiredState).toBe(expected);
|
|
expect(preview.evidence.observations[0]).toMatchObject({
|
|
agent: 'coder0',
|
|
systemd: observation.systemd,
|
|
tmux: observation.tmux,
|
|
preservedAs: expected,
|
|
});
|
|
});
|
|
|
|
it('blocks missing or contradictory lifecycle evidence instead of inferring it', async () => {
|
|
const missing = decisions();
|
|
const missingPreview = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: missing,
|
|
observations: {},
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(missingPreview.status).toBe('blocked');
|
|
expect(missingPreview.canonicalYaml).toBeUndefined();
|
|
expect(missingPreview.canonicalRoster).toBeUndefined();
|
|
expect(missingPreview.evidence.candidate).toBeUndefined();
|
|
expect(missingPreview.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'missing-lifecycle-observation', agent: 'coder0' }),
|
|
);
|
|
|
|
const contradictory = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: observations({ systemd: 'active', tmux: 'missing' }),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(contradictory.status).toBe('blocked');
|
|
expect(contradictory.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'ambiguous-lifecycle-observation', agent: 'coder0' }),
|
|
);
|
|
});
|
|
|
|
it('uses only approved aliases and requires explicit disposition for domain classes', async () => {
|
|
const source = v1Source.replace('class: implementer', 'class: custom-domain');
|
|
const blocked = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(blocked.status).toBe('blocked');
|
|
expect(blocked.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'class-disposition-required', agent: 'coder0' }),
|
|
);
|
|
|
|
const explicit = decisions();
|
|
const coderDecision = explicit.agents.coder0;
|
|
if (!coderDecision) throw new Error('fixture decision missing');
|
|
const explicitDecisions: V1ToV2MigrationDecisions = {
|
|
...explicit,
|
|
agents: {
|
|
...explicit.agents,
|
|
coder0: {
|
|
...coderDecision,
|
|
classDisposition: { action: 'preserve' },
|
|
},
|
|
},
|
|
};
|
|
const ready = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: explicitDecisions,
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(ready.status).toBe('ready');
|
|
expect(ready.canonicalRoster?.agents[0]?.className).toBe('custom-domain');
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'holder_session',
|
|
'tmux:\n socket_name: mosaic-v1\n holder_session: _holder',
|
|
"tmux:\n socket_name: mosaic-v1\n holder_session: ''",
|
|
'tmux.holder_session',
|
|
],
|
|
[
|
|
'holderSession',
|
|
'tmux:\n socket_name: mosaic-v1\n holder_session: _holder',
|
|
"tmux:\n socket_name: mosaic-v1\n holderSession: ''",
|
|
'tmux.holderSession',
|
|
],
|
|
[
|
|
'default working_directory',
|
|
'defaults:\n working_directory: /srv/fleet',
|
|
"defaults:\n working_directory: ''",
|
|
'defaults.working_directory',
|
|
],
|
|
[
|
|
'default workingDirectory',
|
|
'defaults:\n working_directory: /srv/fleet',
|
|
"defaults:\n workingDirectory: ''",
|
|
'defaults.workingDirectory',
|
|
],
|
|
[
|
|
'runtime reset_command',
|
|
'runtimes:\n pi:\n reset_command: /new',
|
|
"runtimes:\n pi:\n reset_command: ''",
|
|
'runtimes.pi.reset_command',
|
|
],
|
|
[
|
|
'runtime resetCommand',
|
|
'runtimes:\n pi:\n reset_command: /new',
|
|
"runtimes:\n pi:\n resetCommand: ''",
|
|
'runtimes.pi.resetCommand',
|
|
],
|
|
['agent alias', ' alias: Coder Zero', " alias: ' '", 'agents[0].alias'],
|
|
[
|
|
'agent working_directory',
|
|
' working_directory: /srv/coder0',
|
|
" working_directory: ''",
|
|
'agents[0].working_directory',
|
|
],
|
|
[
|
|
'agent workingDirectory',
|
|
' working_directory: /srv/coder0',
|
|
" workingDirectory: ''",
|
|
'agents[0].workingDirectory',
|
|
],
|
|
] as const)(
|
|
'blocks present-empty %s instead of silently applying a fallback',
|
|
async (_case, anchor, replacement, expectedPath) => {
|
|
const source = v1Source.replace(anchor, replacement);
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
|
|
expect(preview.status).toBe('blocked');
|
|
expect(preview.canonicalRoster).toBeUndefined();
|
|
expect(preview.blockers).toContainEqual(
|
|
expect.objectContaining({
|
|
code: 'empty-v1-field-disposition-required',
|
|
path: expectedPath,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['absent', undefined, []],
|
|
['regenerate-only', 'MOSAIC_AGENT_NAME=legacy-generated\n', []],
|
|
['relocate-local', 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', ['MOSAIC_RUNTIME_BIN']],
|
|
['quarantine', 'MOSAIC_AGENT_COMMAND=never-publish\n', []],
|
|
] as const)(
|
|
'preserves the %s legacy environment disposition in migration evidence',
|
|
async (expectedLegacy, legacySource, relocatedKeys) => {
|
|
const dirs = await semanticDirs();
|
|
const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-environment-disposition-'));
|
|
temporaryDirectories.push(mosaicHome);
|
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
await chmod(mosaicHome, 0o700);
|
|
await chmod(join(mosaicHome, 'fleet'), 0o700);
|
|
await chmod(agentEnvDir, 0o700);
|
|
if (legacySource !== undefined) {
|
|
await writeFile(join(agentEnvDir, 'coder0.env'), legacySource, { mode: 0o600 });
|
|
}
|
|
|
|
const preview = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
environment: { mosaicHome, agentEnvDir },
|
|
});
|
|
|
|
expect(preview.environment).toContainEqual(
|
|
expect.objectContaining({
|
|
agent: 'coder0',
|
|
legacy: expectedLegacy,
|
|
relocatedKeys: [...relocatedKeys],
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('preflights legacy env relocation and quarantine without exposing values or changing files', async () => {
|
|
const dirs = await semanticDirs();
|
|
const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-migration-home-'));
|
|
temporaryDirectories.push(mosaicHome);
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
const agentEnvDir = join(fleetDir, 'agents');
|
|
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
await chmod(mosaicHome, 0o700);
|
|
await chmod(fleetDir, 0o700);
|
|
await chmod(agentEnvDir, 0o700);
|
|
const forbiddenValue = 'run-privileged --token never-print-this';
|
|
const legacyPath = join(agentEnvDir, 'coder0.env');
|
|
await writeFile(
|
|
legacyPath,
|
|
`MOSAIC_RUNTIME_BIN=/usr/bin/pi\nMOSAIC_AGENT_COMMAND=${forbiddenValue}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
|
|
const preview = await previewV1ToV2Migration({
|
|
source: v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src'),
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
environment: { mosaicHome, agentEnvDir },
|
|
});
|
|
const serialized = JSON.stringify(preview);
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.environment).toEqual([
|
|
expect.objectContaining({
|
|
agent: 'coder0',
|
|
generated: 'rebuild',
|
|
legacy: 'quarantine',
|
|
relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
|
|
diagnostics: [
|
|
expect.objectContaining({
|
|
code: 'unknown-key',
|
|
key: 'MOSAIC_AGENT_COMMAND',
|
|
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
expect(serialized).not.toContain(forbiddenValue);
|
|
expect(
|
|
await import('node:fs/promises').then(({ readFile }) => readFile(legacyPath, 'utf8')),
|
|
).toContain(forbiddenValue);
|
|
});
|
|
|
|
it('feeds a ready home-relative candidate through the production projection boundary', async () => {
|
|
const source = v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src');
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.canonicalRoster?.agents[0]?.workingDirectory).toBe('~/src');
|
|
expect(preview.canonicalYaml).toContain('working_directory: ~/src');
|
|
|
|
const canonicalRoster = preview.canonicalRoster!;
|
|
const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-production-projection-'));
|
|
temporaryDirectories.push(mosaicHome);
|
|
const fleetDir = join(mosaicHome, 'fleet');
|
|
const agentEnvDir = join(fleetDir, 'agents');
|
|
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
await chmod(mosaicHome, 0o700);
|
|
await chmod(fleetDir, 0o700);
|
|
await chmod(agentEnvDir, 0o700);
|
|
|
|
const projectionDirs = await semanticDirs();
|
|
await expect(
|
|
executeFleetReconcile({
|
|
roster: canonicalRoster,
|
|
command: 'plan',
|
|
deps: {
|
|
mosaicHome,
|
|
rolesDir: projectionDirs.rolesDir,
|
|
overrideDir: projectionDirs.overrideDir,
|
|
runner: async (command, args) => {
|
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
|
return { stdout: '', stderr: '', exitCode: 1 };
|
|
}
|
|
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ applied: false, projections: 'not-applied' });
|
|
});
|
|
|
|
it('blocks undeclared v1 fields, synonym collisions, and duplicate names', async () => {
|
|
const baseOptions = {
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
};
|
|
const unknown = await previewV1ToV2Migration({
|
|
...baseOptions,
|
|
source: v1Source.replace('version: 1', 'version: 1\nunknown_root: value'),
|
|
});
|
|
expect(unknown.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'unsupported-v1-field', path: 'unknown_root' }),
|
|
);
|
|
|
|
const collision = await previewV1ToV2Migration({
|
|
...baseOptions,
|
|
source: v1Source.replace(
|
|
'socket_name: mosaic-v1',
|
|
'socket_name: mosaic-v1\n socketName: other',
|
|
),
|
|
});
|
|
expect(collision.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'v1-synonym-collision' }),
|
|
);
|
|
|
|
const duplicate = await previewV1ToV2Migration({
|
|
...baseOptions,
|
|
source: v1Source.replace('name: remote0', 'name: coder0'),
|
|
});
|
|
expect(duplicate.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'duplicate-agent-name', agent: 'coder0' }),
|
|
);
|
|
});
|
|
|
|
it('rejects extra observations and an observed-running disabled disposition', async () => {
|
|
const extra = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: { ...observations(), remote0: { systemd: 'active', tmux: 'present' } },
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(extra.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'extra-lifecycle-observation', agent: 'remote0' }),
|
|
);
|
|
|
|
const base = decisions();
|
|
const coder = base.agents.coder0;
|
|
if (!coder) throw new Error('fixture decision missing');
|
|
const disabled = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: {
|
|
...base,
|
|
agents: { ...base.agents, coder0: { ...coder, enabled: false } },
|
|
},
|
|
observations: observations({ systemd: 'active', tmux: 'present' }),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(disabled.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'disabled-running-observation', agent: 'coder0' }),
|
|
);
|
|
});
|
|
|
|
it('rejects unknown or malformed decision and observation fields before preview', async () => {
|
|
const unknownDecision = decisions() as V1ToV2MigrationDecisions & { unexpected: string };
|
|
unknownDecision.unexpected = 'value';
|
|
await expect(
|
|
previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: unknownDecision,
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
}),
|
|
).rejects.toThrow('decisions.unexpected is not supported');
|
|
|
|
await expect(
|
|
previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: {
|
|
coder0: { systemd: 'active', tmux: 'missing', extra: true },
|
|
} as never,
|
|
personaDirs: await semanticDirs(),
|
|
}),
|
|
).rejects.toThrow('observations.coder0.extra is not supported');
|
|
});
|
|
|
|
it('rejects extra agent decisions and never derives a missing tool policy from class', async () => {
|
|
const base = decisions();
|
|
const coder = base.agents.coder0;
|
|
if (!coder) throw new Error('fixture decision missing');
|
|
const noToolPolicy = v1Source.replace(' tool_policy: implementer\n', '');
|
|
const blocked = await previewV1ToV2Migration({
|
|
source: noToolPolicy,
|
|
decisions: {
|
|
...base,
|
|
agents: { ...base.agents, extra0: coder },
|
|
},
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(blocked.blockers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: 'tool-policy-disposition-required', agent: 'coder0' }),
|
|
expect.objectContaining({ code: 'extra-agent-decision', agent: 'extra0' }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('requires no competing disposition for automatic aliases', async () => {
|
|
const base = decisions();
|
|
const coder = base.agents.coder0;
|
|
if (!coder) throw new Error('fixture decision missing');
|
|
const blocked = await previewV1ToV2Migration({
|
|
source: v1Source,
|
|
decisions: {
|
|
...base,
|
|
agents: {
|
|
coder0: { ...coder, classDisposition: { action: 'replace', className: 'review' } },
|
|
},
|
|
},
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(blocked.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'redundant-class-disposition', agent: 'coder0' }),
|
|
);
|
|
});
|
|
|
|
it('produces byte-stable evidence for repeated YAML and equivalent JSON input', async () => {
|
|
const dirs = await semanticDirs();
|
|
const options = {
|
|
source: v1Source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: dirs,
|
|
};
|
|
const first = await previewV1ToV2Migration(options);
|
|
const second = await previewV1ToV2Migration(options);
|
|
expect(second).toEqual(first);
|
|
|
|
const parsed = (await import('yaml')).default.parse(v1Source) as unknown;
|
|
const json = await previewV1ToV2Migration({ ...options, source: JSON.stringify(parsed) });
|
|
expect(json.canonicalYaml).toBe(first.canonicalYaml);
|
|
expect(json.evidence.candidate).toEqual(first.evidence.candidate);
|
|
});
|
|
|
|
it('inventories Matrix connectors and remote agents without environment preflight', async () => {
|
|
const source = v1Source.replace(
|
|
/connector:\n[\s\S]*$/,
|
|
'connector:\n kind: matrix\n matrix:\n homeserver_url: https://matrix.example.test\n user_id: "@bot:example.test"\n room_id: "!room:example.test"\n',
|
|
);
|
|
const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-remote-no-preflight-'));
|
|
temporaryDirectories.push(mosaicHome);
|
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
await chmod(mosaicHome, 0o700);
|
|
await chmod(join(mosaicHome, 'fleet'), 0o700);
|
|
await chmod(agentEnvDir, 0o700);
|
|
await writeFile(join(agentEnvDir, 'remote0.env'), 'MOSAIC_AGENT_COMMAND=never-publish\n', {
|
|
mode: 0o644,
|
|
});
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
environment: { mosaicHome, agentEnvDir },
|
|
});
|
|
expect(preview.status).toBe('ready');
|
|
expect(preview.evidence.excludedInventory).toEqual(
|
|
expect.arrayContaining(['connector', 'agents.remote0']),
|
|
);
|
|
expect(preview.inventory).toContainEqual(
|
|
expect.objectContaining({ path: 'connector.matrix.room_id', disposition: 'inventory-only' }),
|
|
);
|
|
expect(preview.environment.map(({ agent }) => agent)).toEqual(['coder0']);
|
|
});
|
|
|
|
it('blocks malformed schema-only remote and connector fields', async () => {
|
|
const source = v1Source
|
|
.replace('host: build.example.test', 'host: 42')
|
|
.replace('discord:\n channel_id: channel', 'discord: {}');
|
|
const preview = await previewV1ToV2Migration({
|
|
source,
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(preview.status).toBe('blocked');
|
|
expect(preview.blockers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].host' }),
|
|
expect.objectContaining({
|
|
code: 'invalid-v1-field-type',
|
|
path: 'connector.discord.channel_id',
|
|
}),
|
|
]),
|
|
);
|
|
const malformedRoot = await previewV1ToV2Migration({
|
|
source: v1Source.replace(
|
|
'tmux:\n socket_name: mosaic-v1\n holder_session: _holder',
|
|
'tmux: []',
|
|
),
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(malformedRoot.blockers).toContainEqual(
|
|
expect.objectContaining({ code: 'invalid-v1-field-type', path: 'tmux' }),
|
|
);
|
|
|
|
const malformedScalars = await previewV1ToV2Migration({
|
|
source: v1Source
|
|
.replace('alias: Coder Zero', 'alias: 42')
|
|
.replace('reset_between_tasks: false', 'reset_between_tasks: "false"')
|
|
.replace(
|
|
' runtime: pi\n class: reviewer\n host:',
|
|
' runtime: ~\n class: reviewer\n host:',
|
|
),
|
|
decisions: decisions(),
|
|
observations: observations(),
|
|
personaDirs: await semanticDirs(),
|
|
});
|
|
expect(malformedScalars.blockers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[0].alias' }),
|
|
expect.objectContaining({
|
|
code: 'invalid-v1-field-type',
|
|
path: 'agents[0].reset_between_tasks',
|
|
}),
|
|
expect.objectContaining({ code: 'invalid-v1-field-type', path: 'agents[1].runtime' }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('validates all 13 shipped artifacts and executes ready previews for every v1 fixture', async () => {
|
|
const dispositions = await validateShippedFleetMigrationDispositions({
|
|
frameworkFleet,
|
|
v1Previews: await shippedV1PreviewEvidence(),
|
|
});
|
|
expect(dispositions).toHaveLength(13);
|
|
expect(dispositions.map(({ path }) => path)).toEqual(
|
|
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path),
|
|
);
|
|
await expect(
|
|
validateShippedFleetMigrationDispositions({ frameworkFleet, v1Previews: {} }),
|
|
).rejects.toThrow('Missing explicit migration evidence');
|
|
});
|
|
|
|
it('emits an explicit M4 disposition for every shipped M1 inventory entry', () => {
|
|
const dispositions = collectShippedFleetMigrationDispositions();
|
|
expect(dispositions.map(({ path }) => path)).toEqual(
|
|
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }) => path),
|
|
);
|
|
expect(dispositions).toHaveLength(13);
|
|
expect(dispositions.every(({ migrationDisposition }) => migrationDisposition.length > 0)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|