feat(fleet): add reviewed v1-to-v2 migration preview
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-07-15 22:04:51 -05:00
parent c1aecfabe9
commit 0e814324ca
21 changed files with 3282 additions and 45 deletions

View File

@@ -0,0 +1,18 @@
/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */
export function compareCodePoints(left: string, right: string): number {
const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0);
const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0);
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < sharedLength; index += 1) {
const leftPoint = leftPoints[index];
const rightPoint = rightPoints[index];
if (leftPoint === undefined || rightPoint === undefined) continue;
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
}
return leftPoints.length < rightPoints.length
? -1
: leftPoints.length > rightPoints.length
? 1
: 0;
}

View File

@@ -303,25 +303,22 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
expect(process.exitCode).toBe(0);
});
it.each([
{
label: 'missing',
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''),
},
{
label: 'empty',
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'),
},
])(
'rejects a $label canonical roster-v2 tmux socket through parseRosterV2',
({ source }): void => {
expect(() => parseRosterV2(source, 'yaml')).toThrow(
'Roster v2 tmux socket_name is required and must be a non-empty string.',
);
},
);
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
expect(() => parseRosterV2(source, 'yaml')).toThrow(
'Roster v2 tmux socket_name is required and must be a string.',
);
});
it('uses the literal default tmux server only through the legacy-v1 loader and runtime transport boundary', async (): Promise<void> => {
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => {
const source = renderRosterV2Yaml(baseRoster).replace(
/^ socket_name:.*$/m,
' socket_name: ""',
);
expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe('');
});
it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise<void> => {
const rosterPath = await fixtureLegacyRoster();
const runner = vi.fn<CommandRunner>(
async (): Promise<CommandResult> => ({

View File

@@ -274,6 +274,53 @@ describe('fleet roster-owned reconciler', (): void => {
]);
});
it.each(['start', 'apply'] as const)(
'fails closed before %s can start fixed named-socket services for a default-server roster',
async (command) => {
const calls: string[][] = [];
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
agents: [
{
...roster.agents[0]!,
lifecycle: { enabled: true, desiredState: 'running' },
},
],
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
expectedGeneration: 7,
deps: deps({
readRoster: async () => defaultServerRoster,
runner: async (executable, args) => {
calls.push([executable, ...args]);
if (executable === 'tmux' && args.includes('list-sessions')) {
return { stdout: '', stderr: '', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
}),
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-tmux-holder.service',
]);
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-agent@coder0.service',
]);
},
);
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
const calls: string[][] = [];
const runningRoster: FleetRosterV2 = {

View File

@@ -176,6 +176,7 @@ export async function executeFleetReconcile(
assertLocalRosterOnly(request.roster);
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
await validateRoster(request.roster);
assertLifecycleSocketAuthority(request);
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
if (request.command === 'status' || request.command === 'doctor') {
@@ -477,6 +478,23 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void {
}
}
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
const mayStart =
request.command === 'start' ||
request.command === 'restart' ||
((request.command === 'apply' || request.command === 'reconcile') &&
request.roster.agents.some(
(agent: FleetRosterV2Agent): boolean =>
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
));
if (mayStart && request.roster.tmux.socketName === '') {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Default-server lifecycle start is unsupported by the fixed named-socket systemd units.',
);
}
}
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
if (agentName === undefined) return roster.agents;
const agent = roster.agents.find(

View File

@@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
AgentEnvBoundaryError,
parseAgentEnvironment,
previewAgentEnvironmentProjection,
renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection,
} from './generated-env-boundary.js';
@@ -86,6 +87,41 @@ describe('generated fleet agent environment boundary', (): void => {
}).toThrow(AgentEnvBoundaryError);
});
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const legacyPath = join(agentEnvDir, 'coder0.env');
const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n';
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(legacyPath, legacy, { mode: 0o600 });
const preview = await previewAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
expect(preview).toMatchObject({
agentName: '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(JSON.stringify(preview)).not.toContain('never-print-command');
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy);
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow();
});
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');

View File

@@ -1,6 +1,7 @@
import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local';
@@ -30,6 +31,15 @@ export interface AgentEnvironmentProjectionResult {
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** Sanitized, non-mutating projection evidence safe for migration output. */
export interface AgentEnvironmentProjectionPreview {
readonly agentName: string;
readonly generated: 'rebuild';
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
readonly relocatedKeys: readonly string[];
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** A projection fully validated without changing managed files. */
export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string;
@@ -40,6 +50,7 @@ export interface PreparedAgentEnvironmentProjection {
readonly generated: string;
readonly local: string;
readonly legacy?: string;
readonly legacyRelocatedKeys: readonly string[];
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
@@ -186,6 +197,7 @@ export async function prepareAgentEnvironmentProjection(
generated,
local,
...(legacy === undefined ? {} : { legacy }),
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics,
};
@@ -208,6 +220,33 @@ export async function prepareAgentGeneratedProjectionDeletion(
return generatedPath;
}
export async function previewAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<AgentEnvironmentProjectionPreview> {
const prepared = await prepareAgentEnvironmentProjection(options);
const relocatedKeys = prepared.legacyRelocatedKeys;
const legacy =
prepared.legacy === undefined
? 'absent'
: prepared.diagnostics.length > 0
? 'quarantine'
: relocatedKeys.length > 0
? 'relocate-local'
: 'regenerate-only';
return {
agentName: options.agentName,
generated: 'rebuild',
legacy,
relocatedKeys,
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
compareCodePoints(
`${left.key}:${left.code}:${left.sha256}`,
`${right.key}:${right.code}:${right.sha256}`,
),
),
};
}
/** Applies a previously prepared deterministic projection. */
export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection,
@@ -292,13 +331,13 @@ function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>):
if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment(
Object.entries(values)
.sort(([left], [right]): number => left.localeCompare(right))
.sort(([left], [right]): number => compareCodePoints(left, right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n'),
'local',
);
return `${Object.entries(parsed)
.sort(([left], [right]): number => left.localeCompare(right))
.sort(([left], [right]): number => compareCodePoints(left, right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`;
}

View File

@@ -45,6 +45,12 @@ agents:
let semanticTmp: string | undefined;
it('preserves an explicit empty socket as the literal default tmux server', () => {
const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''"));
expect(roster.tmux.socketName).toBe('');
expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""');
});
afterEach(async (): Promise<void> => {
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
semanticTmp = undefined;

View File

@@ -10,6 +10,7 @@ import {
type PersonaResolution,
type RoleAuthority,
} from '../commands/fleet-personas.js';
import { compareCodePoints } from './deterministic-order.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -172,7 +173,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
additionalProperties: false,
required: ['socket_name', 'holder_session'],
properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
},
},
@@ -272,6 +273,7 @@ const AGENT_KEYS = [
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
@@ -327,7 +329,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return {
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
};
}
@@ -348,7 +350,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort()) {
for (const name of names.sort(compareCodePoints)) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
@@ -416,7 +418,7 @@ function normalizeAgents(
};
});
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
compareCodePoints(left.name, right.name),
);
}
@@ -473,6 +475,17 @@ function requiredIdentifier(value: unknown, label: string): string {
return result;
}
function requiredTmuxSocket(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new RosterV2ValidationError(`${label} is required and must be a string.`);
}
const result = value.trim();
if (!TMUX_SOCKET_IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result))
@@ -524,7 +537,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
},
runtimes: Object.fromEntries(
Object.entries(roster.runtimes)
.sort(([left], [right]): number => left.localeCompare(right))
.sort(([left], [right]): number => compareCodePoints(left, right))
.map(([name, runtime]): [string, unknown] => [
name,
{ reset_command: runtime.resetCommand },
@@ -532,7 +545,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
),
agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
compareCodePoints(left.name, right.name),
)
.map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff