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 => { await Promise.all( temporaryDirectories .splice(0) .map((path): Promise => 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 { return { coder0: observation }; } async function shippedV1PreviewEvidence(): Promise< Record< string, { decisions: V1ToV2MigrationDecisions; observations: Record; } > > { const evidence: Record< string, { decisions: V1ToV2MigrationDecisions; observations: Record; } > = {}; const explicitReplacements: Readonly> = { worker: 'code', canary: 'code', analyst: 'researcher', }; const aliases: Readonly> = { 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 = {}; 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: '' }, }).toEqual({ ...previewA.evidence, source: { ...previewA.evidence.source, sha256: '' }, }); 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: '' }, }, }); 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 => { 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, ); }); });