Files
stack/packages/mosaic/src/fleet/v1-v2-migration.spec.ts
Jarvis 04891422b3
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
feat(fleet): harden v1 migration preview
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:57:33 -05:00

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,
);
});
});