Files
stack/packages/mosaic/src/fleet/v1-v2-migration.ts
Jarvis d63bb0206a
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
fix(fleet): preserve exact v1 source strings
2026-07-16 07:55:10 -05:00

1507 lines
50 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import YAML from 'yaml';
import { canonicalizeRoleClass, type PersonaDirs } from '../commands/fleet-personas.js';
import { compareCodePoints } from './deterministic-order.js';
import {
previewAgentEnvironmentProjection,
type AgentEnvironmentDiagnostic,
} from './generated-env-boundary.js';
import {
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
validateShippedFleetArtifactDispositions,
type FleetArtifactDisposition,
type ValidateShippedFleetArtifactDispositionsOptions,
} from './example-profile-dispositions.js';
import {
parseRosterV2,
renderRosterV2Yaml,
validateRosterV2Semantics,
type FleetRosterV2,
type RosterV2DesiredState,
type RosterV2ReasoningLevel,
type RosterV2RuntimeName,
} from './roster-v2.js';
const AUTOMATIC_CLASSES = new Set([
'code',
'review',
'interaction',
'merge-gate',
'validator',
'orchestrator',
'team-leader',
'enhancer',
]);
const V1_RUNTIME_RESETS: Readonly<Record<RosterV2RuntimeName, string>> = Object.freeze({
claude: '/clear',
codex: '/clear',
opencode: '/clear',
pi: '/new',
});
const REMOTE_AGENT_INVENTORY_FIELDS = ['host', 'socket', 'ssh'] as const;
const ROOT_FIELDS = new Set([
'version',
'transport',
'tmux',
'defaults',
'runtimes',
'agents',
'connector',
]);
const TMUX_FIELDS = new Set(['socket_name', 'socketName', 'holder_session', 'holderSession']);
const DEFAULT_FIELDS = new Set(['working_directory', 'workingDirectory']);
const RUNTIME_FIELDS = new Set(['reset_command', 'resetCommand']);
const DECISION_ROOT_FIELDS = new Set([
'generation',
'fleetHost',
'socketName',
'defaultRuntime',
'agents',
]);
const AGENT_DECISION_FIELDS = new Set([
'provider',
'model',
'reasoning',
'enabled',
'launchYolo',
'classDisposition',
'toolPolicyDisposition',
'kickstartTemplate',
'persistentPersona',
]);
const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']);
const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']);
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
const V1_TMUX_SOCKET_PATTERN = /^[A-Za-z0-9_.-]+$/;
const V1_REMOTE_TARGET_PATTERNS: Readonly<
Record<(typeof REMOTE_AGENT_INVENTORY_FIELDS)[number], RegExp>
> = Object.freeze({
host: /^[A-Za-z0-9_.:[\]-]+$/,
ssh: /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
socket: V1_TMUX_SOCKET_PATTERN,
});
const AGENT_FIELDS = new Set([
'name',
'alias',
'provider',
'runtime',
'class',
'host',
'ssh',
'socket',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'reasoning_level',
'reasoningLevel',
'tool_policy',
'toolPolicy',
'persistent_persona',
'persistentPersona',
'reset_between_tasks',
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
]);
export type MigrationObservationSystemd = 'active' | 'inactive' | 'unknown';
export type MigrationObservationTmux = 'present' | 'missing';
export interface V1MigrationObservation {
readonly systemd: MigrationObservationSystemd;
readonly tmux: MigrationObservationTmux;
}
export interface V1ClassDisposition {
readonly action: 'preserve' | 'replace';
readonly className?: string;
}
export interface V1AgentMigrationDecision {
readonly provider: string;
readonly model: string;
readonly reasoning: RosterV2ReasoningLevel;
readonly enabled: boolean;
readonly launchYolo: boolean;
readonly classDisposition?: V1ClassDisposition;
readonly toolPolicyDisposition?: V1ClassDisposition;
readonly kickstartTemplate?: 'inventory-only';
readonly persistentPersona?: boolean;
}
export interface V1ToV2MigrationDecisions {
readonly generation: number;
readonly fleetHost?: string;
readonly socketName?: string;
readonly defaultRuntime?: RosterV2RuntimeName;
readonly agents: Record<string, V1AgentMigrationDecision>;
}
export interface V1ToV2MigrationPreviewOptions {
readonly source: string;
readonly sourcePath?: string;
readonly decisions: V1ToV2MigrationDecisions;
readonly observations: Readonly<Record<string, V1MigrationObservation>>;
readonly personaDirs?: PersonaDirs;
readonly environment?: {
readonly mosaicHome: string;
readonly agentEnvDir: string;
};
}
export interface MigrationInventoryEntry {
readonly path: string;
readonly disposition: 'candidate' | 'inventory-only' | 'requires-decision';
}
export interface MigrationBlocker {
readonly code: string;
readonly path: string;
readonly agent?: string;
readonly detail: string;
}
export interface InventoryOnlyAgent {
readonly name: string;
readonly fields: readonly string[];
readonly disposition: 'inventory-only';
}
export interface EnvironmentMigrationPreview {
readonly agent: string;
readonly generated: 'rebuild';
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
readonly relocatedKeys: readonly string[];
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
export interface V1ToV2MigrationEvidence {
readonly source: { readonly path?: string; readonly sha256: string };
readonly candidate?: { readonly sha256: string; readonly generation: number };
readonly observations: readonly {
readonly agent: string;
readonly systemd: MigrationObservationSystemd;
readonly tmux: MigrationObservationTmux;
readonly preservedAs: RosterV2DesiredState;
}[];
readonly excludedInventory: readonly string[];
readonly recovery: {
readonly executable: false;
readonly canaryExecuted: false;
readonly rollbackExecuted: false;
readonly requiredArtifacts: readonly string[];
readonly gateOwner: 'FCM-M4-002';
};
}
export interface V1ToV2MigrationPreview {
readonly status: 'ready' | 'blocked';
readonly inventory: readonly MigrationInventoryEntry[];
readonly inventoryOnlyAgents: readonly InventoryOnlyAgent[];
readonly blockers: readonly MigrationBlocker[];
readonly canonicalRoster?: FleetRosterV2;
readonly canonicalYaml?: string;
readonly environment: readonly EnvironmentMigrationPreview[];
readonly evidence: V1ToV2MigrationEvidence;
}
export interface ShippedFleetMigrationDisposition {
readonly path: string;
readonly currentDisposition: FleetArtifactDisposition;
readonly migrationDisposition: string;
}
export interface ShippedV1MigrationPreviewEvidence {
readonly decisions: V1ToV2MigrationDecisions;
readonly observations: Readonly<Record<string, V1MigrationObservation>>;
}
export interface ValidateShippedFleetMigrationDispositionsOptions extends ValidateShippedFleetArtifactDispositionsOptions {
readonly v1Previews: Readonly<Record<string, ShippedV1MigrationPreviewEvidence>>;
}
/** Strictly validates the untrusted JSON decision document before migration preview. */
export function parseV1ToV2MigrationDecisions(value: unknown): V1ToV2MigrationDecisions {
const root = requireRecord(value, 'decisions');
assertOnlyKeys(root, DECISION_ROOT_FIELDS, 'decisions');
const generation = root.generation;
if (!Number.isSafeInteger(generation) || (generation as number) <= 0) {
throw new Error('decisions.generation must be a positive safe integer.');
}
const defaultRuntime = root.defaultRuntime;
if (defaultRuntime !== undefined && !isRuntime(stringValue(defaultRuntime))) {
throw new Error('decisions.defaultRuntime must be a supported runtime.');
}
const fleetHost = optionalNonEmptyString(root.fleetHost, 'decisions.fleetHost');
const socketName = optionalNonEmptyString(root.socketName, 'decisions.socketName');
const rawAgents = requireRecord(root.agents, 'decisions.agents');
const agents: Record<string, V1AgentMigrationDecision> = {};
for (const [name, rawDecision] of Object.entries(rawAgents)) {
if (!AGENT_NAME_PATTERN.test(name)) {
throw new Error('decisions.agents contains an invalid agent identifier.');
}
const decision = requireRecord(rawDecision, `decisions.agents.${name}`);
assertOnlyKeys(decision, AGENT_DECISION_FIELDS, `decisions.agents.${name}`);
const provider = requiredNonEmptyString(decision.provider, `decisions.agents.${name}.provider`);
const model = requiredNonEmptyString(decision.model, `decisions.agents.${name}.model`);
const reasoning = decision.reasoning;
if (reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high') {
throw new Error(`decisions.agents.${name}.reasoning must be low, medium, or high.`);
}
if (typeof decision.enabled !== 'boolean') {
throw new Error(`decisions.agents.${name}.enabled must be boolean.`);
}
if (typeof decision.launchYolo !== 'boolean') {
throw new Error(`decisions.agents.${name}.launchYolo must be boolean.`);
}
const kickstartTemplate = decision.kickstartTemplate;
if (kickstartTemplate !== undefined && kickstartTemplate !== 'inventory-only') {
throw new Error(
`decisions.agents.${name}.kickstartTemplate must be inventory-only when present.`,
);
}
if (
decision.persistentPersona !== undefined &&
typeof decision.persistentPersona !== 'boolean'
) {
throw new Error(`decisions.agents.${name}.persistentPersona must be boolean when present.`);
}
const classDisposition = parseClassDisposition(
decision.classDisposition,
`decisions.agents.${name}.classDisposition`,
);
const toolPolicyDisposition = parseClassDisposition(
decision.toolPolicyDisposition,
`decisions.agents.${name}.toolPolicyDisposition`,
);
agents[name] = {
provider,
model,
reasoning,
enabled: decision.enabled,
launchYolo: decision.launchYolo,
...(classDisposition ? { classDisposition } : {}),
...(toolPolicyDisposition ? { toolPolicyDisposition } : {}),
...(kickstartTemplate ? { kickstartTemplate } : {}),
...(typeof decision.persistentPersona === 'boolean'
? { persistentPersona: decision.persistentPersona }
: {}),
};
}
const normalizedDefaultRuntime = stringValue(defaultRuntime);
return {
generation: generation as number,
...(fleetHost ? { fleetHost } : {}),
...(socketName ? { socketName } : {}),
...(isRuntime(normalizedDefaultRuntime) ? { defaultRuntime: normalizedDefaultRuntime } : {}),
agents,
};
}
/** Strictly validates the untrusted JSON lifecycle-observation document. */
export function parseV1MigrationObservations(
value: unknown,
): Readonly<Record<string, V1MigrationObservation>> {
const root = requireRecord(value, 'observations');
const observations: Record<string, V1MigrationObservation> = {};
for (const [name, rawObservation] of Object.entries(root)) {
if (!AGENT_NAME_PATTERN.test(name)) {
throw new Error('observations contains an invalid agent identifier.');
}
const observation = requireRecord(rawObservation, `observations.${name}`);
assertOnlyKeys(observation, OBSERVATION_FIELDS, `observations.${name}`);
const systemd = observation.systemd;
const tmux = observation.tmux;
if (systemd !== 'active' && systemd !== 'inactive' && systemd !== 'unknown') {
throw new Error(`observations.${name}.systemd has an unsupported state.`);
}
if (tmux !== 'present' && tmux !== 'missing') {
throw new Error(`observations.${name}.tmux has an unsupported state.`);
}
observations[name] = { systemd, tmux };
}
return observations;
}
/**
* Builds a complete, non-mutating migration preview. It never runs systemd,
* tmux, a connector, or the reconciler, and it never writes prepared files.
*/
export async function previewV1ToV2Migration(
options: V1ToV2MigrationPreviewOptions,
): Promise<V1ToV2MigrationPreview> {
const decisions = parseV1ToV2MigrationDecisions(options.decisions);
const reviewedObservations = parseV1MigrationObservations(options.observations);
const raw = parseV1Source(options.source);
const inventory = inventorySource(raw, decisions.fleetHost);
const blockers: MigrationBlocker[] = [];
const inventoryOnlyAgents: InventoryOnlyAgent[] = [];
const observations: Array<{
agent: string;
systemd: MigrationObservationSystemd;
tmux: MigrationObservationTmux;
preservedAs: RosterV2DesiredState;
}> = [];
assertV1Root(raw, blockers);
validateV1FieldCatalog(raw, blockers);
blockPresentEmptyV1Fields(raw, decisions.fleetHost, blockers);
blockLossyV1StringNormalization(raw, decisions.fleetHost, blockers);
const tmux = record(raw.tmux);
const defaults = record(raw.defaults);
const runtimes = normalizeRuntimes(record(raw.runtimes), blockers);
const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name');
const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName');
const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket;
const declaredSocketValue = hasDeclaredSnakeSocket ? tmux.socket_name : tmux.socketName;
const sourceSocketName = typeof declaredSocketValue === 'string' ? declaredSocketValue : '';
const declaredSocketPath = hasDeclaredSnakeSocket ? 'tmux.socket_name' : 'tmux.socketName';
if (sourceSocketName && !V1_TMUX_SOCKET_PATTERN.test(sourceSocketName)) {
blockers.push(
blocker(
'invalid-v1-targeting-characters',
declaredSocketPath,
'Fleet tmux socket contains unsupported targeting characters.',
),
);
}
const socketName = sourceSocketName;
if (
hasDeclaredSocket &&
decisions.socketName !== undefined &&
decisions.socketName !== sourceSocketName
) {
blockers.push(
blocker(
'socket-name-conflict',
'decisions.socketName',
'Decision socket must match the socket declared by the v1 roster.',
),
);
}
const rawAgents = Array.isArray(raw.agents) ? raw.agents : [];
const candidateAgents: Record<string, unknown>[] = [];
for (let index = 0; index < rawAgents.length; index += 1) {
const rawAgent = record(rawAgents[index]);
const name = exactStringValue(rawAgent.name);
const path = `agents[${index}]`;
if (!name) {
blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.'));
continue;
}
const agentSocket = typeof rawAgent.socket === 'string' ? rawAgent.socket : undefined;
if (agentSocket !== undefined && agentSocket !== socketName) {
blockers.push(
blocker(
'agent-socket-disposition-required',
`${path}.socket`,
'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
name,
),
);
}
const locality = classifyAgentLocality(rawAgent, decisions.fleetHost);
const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter(
(field) => rawAgent[field] !== undefined,
);
if (locality === 'remote') {
inventoryOnlyAgents.push({
name,
fields: [...localityFields].sort(compareCodePoints),
disposition: 'inventory-only',
});
continue;
}
if (locality === 'ambiguous') {
blockers.push(
blocker(
'agent-locality-disposition-required',
`${path}.host`,
'Agent locality is ambiguous or contradicts the reviewed fleet-host identity.',
name,
),
);
}
const decision = decisions.agents[name];
if (!decision) {
blockers.push(
blocker(
'agent-disposition-required',
path,
'Local agent requires explicit migration decisions.',
name,
),
);
continue;
}
const runtime = exactStringValue(rawAgent.runtime);
if (!isRuntime(runtime)) {
blockers.push(
blocker('unsupported-runtime', `${path}.runtime`, 'Runtime must be supported.', name),
);
}
const requestedClass = exactStringValue(rawAgent.class) ?? 'worker';
const className = resolveClass(
requestedClass,
decision.classDisposition,
`${path}.class`,
name,
blockers,
);
const requestedToolPolicy =
exactStringValue(rawAgent.tool_policy) ?? exactStringValue(rawAgent.toolPolicy);
const toolPolicy = resolveToolPolicy(
requestedToolPolicy,
decision.toolPolicyDisposition,
`${path}.tool_policy`,
name,
blockers,
);
const observation = reviewedObservations[name];
const desiredState = resolveLifecycle(observation, path, name, blockers);
if (desiredState && observation) {
if (desiredState === 'running' && !decision.enabled) {
blockers.push(
blocker(
'disabled-running-observation',
`${path}.lifecycle`,
'Observed-running agent cannot migrate as disabled.',
name,
),
);
}
observations.push({ agent: name, ...observation, preservedAs: desiredState });
}
const persistentRaw = rawAgent.persistent_persona ?? rawAgent.persistentPersona;
let persistentPersona: boolean;
if (typeof persistentRaw === 'boolean') persistentPersona = persistentRaw;
else if (persistentRaw === undefined) persistentPersona = decision.persistentPersona ?? false;
else if (decision.persistentPersona !== undefined)
persistentPersona = decision.persistentPersona;
else {
blockers.push(
blocker(
'persistent-persona-disposition-required',
`${path}.persistent_persona`,
'String persistent_persona requires an explicit boolean disposition.',
name,
),
);
persistentPersona = false;
}
if (
(rawAgent.kickstart_template !== undefined || rawAgent.kickstartTemplate !== undefined) &&
decision.kickstartTemplate !== 'inventory-only'
) {
blockers.push(
blocker(
'kickstart-template-disposition-required',
`${path}.kickstart_template`,
'kickstart_template has no v2 field and must be explicitly retained as inventory-only.',
name,
),
);
}
if (!runtime || !className || !toolPolicy || !desiredState) continue;
candidateAgents.push({
name,
alias: exactStringValue(rawAgent.alias) ?? name,
class: className,
runtime,
provider: decision.provider,
model: decision.model,
reasoning: decision.reasoning,
tool_policy: toolPolicy,
working_directory:
exactStringValue(rawAgent.working_directory) ??
exactStringValue(rawAgent.workingDirectory) ??
exactStringValue(defaults.working_directory) ??
exactStringValue(defaults.workingDirectory) ??
'~/src',
persistent_persona: persistentPersona,
reset_between_tasks:
booleanValue(rawAgent.reset_between_tasks) ??
booleanValue(rawAgent.resetBetweenTasks) ??
false,
lifecycle: { enabled: decision.enabled, desired_state: desiredState },
launch: { yolo: decision.launchYolo },
});
}
const localAgentNames = new Set(
rawAgents
.map((candidate) => record(candidate))
.filter((candidate) => classifyAgentLocality(candidate, decisions.fleetHost) !== 'remote')
.map((candidate) => exactStringValue(candidate.name))
.filter((name): name is string => name !== undefined),
);
for (const decisionName of Object.keys(decisions.agents)) {
if (!localAgentNames.has(decisionName)) {
blockers.push(
blocker(
'extra-agent-decision',
`decisions.agents.${decisionName}`,
'Decision does not correspond to a local migration candidate.',
decisionName,
),
);
}
}
for (const observedName of Object.keys(reviewedObservations)) {
if (!localAgentNames.has(observedName)) {
blockers.push(
blocker(
'extra-lifecycle-observation',
`observations.${observedName}`,
'Observation does not correspond to a local migration candidate.',
observedName,
),
);
}
}
if (!decisions.defaultRuntime) {
blockers.push(
blocker(
'default-runtime-disposition-required',
'defaults.runtime',
'v1 has no default runtime; an explicit v2 default is required.',
),
);
}
if (candidateAgents.length === 0) {
blockers.push(
blocker('no-local-candidates', 'agents', 'No local agents are eligible for v2 output.'),
);
}
let canonicalRoster: FleetRosterV2 | undefined;
let canonicalYaml: string | undefined;
const environment: EnvironmentMigrationPreview[] = [];
if (blockers.length === 0 && decisions.defaultRuntime) {
const defaultRuntime = decisions.defaultRuntime;
try {
canonicalRoster = parseRosterV2(
YAML.stringify({
version: 2,
generation: decisions.generation,
transport: 'tmux',
tmux: {
socket_name: socketName,
holder_session:
exactStringValue(tmux.holder_session) ??
exactStringValue(tmux.holderSession) ??
'_holder',
},
defaults: {
working_directory:
exactStringValue(defaults.working_directory) ??
exactStringValue(defaults.workingDirectory) ??
'~/src',
runtime: defaultRuntime,
},
runtimes,
agents: candidateAgents,
}),
'yaml',
);
canonicalYaml = renderRosterV2Yaml(canonicalRoster);
await validateRosterV2Semantics(canonicalRoster, options.personaDirs);
if (options.environment) {
for (const agent of canonicalRoster.agents) {
try {
const prepared = await previewAgentEnvironmentProjection({
...options.environment,
agentName: agent.name,
generated: generatedValues(canonicalRoster, agent),
});
environment.push({
agent: agent.name,
generated: 'rebuild',
legacy: prepared.legacy,
relocatedKeys: prepared.relocatedKeys,
diagnostics: prepared.diagnostics,
});
} catch (error: unknown) {
const diagnostic = safeEnvironmentDiagnostic(error);
blockers.push({
code: 'environment-preflight-failed',
path: `agents.${agent.name}.environment`,
agent: agent.name,
detail: diagnostic,
});
}
}
}
} catch {
blockers.push(
blocker(
'v2-compilation-failed',
'candidate',
'Candidate failed canonical v2 compilation or semantic validation.',
),
);
canonicalRoster = undefined;
canonicalYaml = undefined;
}
}
const canonicalInventoryOnlyAgents = [...inventoryOnlyAgents].sort((left, right): number =>
compareCodePoints(left.name, right.name),
);
const status = blockers.length === 0 ? 'ready' : 'blocked';
return {
status,
inventory,
inventoryOnlyAgents: canonicalInventoryOnlyAgents,
blockers,
...(status === 'ready' && canonicalRoster && canonicalYaml
? { canonicalRoster, canonicalYaml }
: {}),
environment,
evidence: {
source: {
...(options.sourcePath ? { path: options.sourcePath } : {}),
sha256: sha256(options.source),
},
...(status === 'ready' && canonicalYaml
? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } }
: {}),
observations: [...observations].sort((left, right): number =>
compareCodePoints(left.agent, right.agent),
),
excludedInventory: [
...(raw.connector === undefined ? [] : ['connector']),
...canonicalInventoryOnlyAgents.map(({ name }): string => `agents.${name}`),
],
recovery: {
executable: false,
canaryExecuted: false,
rollbackExecuted: false,
requiredArtifacts: [
'v1-roster-backup',
'environment-backup',
'observed-lifecycle-evidence',
'candidate-v2-roster',
],
gateOwner: 'FCM-M4-002',
},
},
};
}
/**
* Runs the existing executable M1 guard before emitting the exact M4 migration posture.
* New, removed, or invalid shipped artifacts therefore fail closed through one inventory owner.
*/
export async function validateShippedFleetMigrationDispositions(
options: ValidateShippedFleetMigrationDispositionsOptions,
): Promise<readonly ShippedFleetMigrationDisposition[]> {
await validateShippedFleetArtifactDispositions(options);
const v1Fixtures = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
({ disposition }): boolean => disposition === 'v1-fixture',
);
const expected = new Set(v1Fixtures.map(({ path }): string => path));
for (const supplied of Object.keys(options.v1Previews)) {
if (!expected.has(supplied)) {
throw new Error('Migration evidence was supplied for an unknown v1 fixture.');
}
}
for (const artifact of v1Fixtures) {
const evidence = options.v1Previews[artifact.path];
if (!evidence) throw new Error(`Missing explicit migration evidence for ${artifact.path}.`);
const source = await readFile(join(options.frameworkFleet, artifact.path), 'utf8');
const preview = await previewV1ToV2Migration({
source,
sourcePath: artifact.path,
decisions: evidence.decisions,
observations: evidence.observations,
personaDirs: {
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
},
});
if (preview.status !== 'ready') {
throw new Error(
`Shipped v1 fixture migration preview is blocked: ${artifact.path} (${preview.blockers
.map(({ code, path }) => `${code}:${path}`)
.join(', ')}).`,
);
}
}
return collectShippedFleetMigrationDispositions();
}
/** Adds M4 migration posture to the exact executable M1 artifact inventory. */
export function collectShippedFleetMigrationDispositions(): readonly ShippedFleetMigrationDisposition[] {
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map((artifact) => ({
path: artifact.path,
currentDisposition: artifact.disposition,
migrationDisposition:
artifact.disposition === 'v1-fixture'
? 'preview-with-explicit-class-and-lifecycle-evidence'
: artifact.disposition === 'canonical-profile'
? 'retain-through-shared-persona-resolver'
: 'retain-generic-policy-with-approved-tool-policy-alias',
}));
}
function parseV1Source(source: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = source.trimStart().startsWith('{') ? JSON.parse(source) : YAML.parse(source);
} catch (error: unknown) {
throw new Error(
`V1 roster parse failed: ${error instanceof Error ? error.message : 'unknown parse error'}`,
);
}
return record(parsed);
}
type AgentLocality = 'local' | 'remote' | 'ambiguous';
function classifyAgentLocality(
agent: Record<string, unknown>,
fleetHost: string | undefined,
): AgentLocality {
const host = exactStringValue(agent.host);
const ssh = exactStringValue(agent.ssh);
const hasHost = agent.host !== undefined;
const hasSsh = agent.ssh !== undefined;
if (!hasHost && !hasSsh) return 'local';
if (!host || !fleetHost) return 'ambiguous';
if (hasSsh) {
if (!ssh) return 'ambiguous';
const separator = ssh.lastIndexOf('@');
const sshHost = separator >= 0 ? ssh.slice(separator + 1) : ssh;
if (sshHost !== host) return 'ambiguous';
}
return host === fleetHost ? 'local' : 'remote';
}
function inventorySource(
value: Record<string, unknown>,
fleetHost: string | undefined,
): readonly MigrationInventoryEntry[] {
const paths: MigrationInventoryEntry[] = [];
walkInventory(value, '', paths);
const remoteAgentPrefixes = (Array.isArray(value.agents) ? value.agents : [])
.map((agent, index): string | undefined =>
classifyAgentLocality(record(agent), fleetHost) === 'remote' ? `agents[${index}]` : undefined,
)
.filter((prefix): prefix is string => prefix !== undefined);
return paths
.map(
(entry): MigrationInventoryEntry =>
remoteAgentPrefixes.some(
(prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}.`),
)
? { ...entry, disposition: 'inventory-only' }
: entry,
)
.sort((left, right) => compareCodePoints(left.path, right.path));
}
function walkInventory(value: unknown, path: string, output: MigrationInventoryEntry[]): void {
if (Array.isArray(value)) {
value.forEach((entry, index) => walkInventory(entry, `${path}[${index}]`, output));
return;
}
if (typeof value === 'object' && value !== null) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const entryPath = path ? `${path}.${key}` : key;
output.push(inventoryEntry(entryPath));
if (typeof entry === 'object' && entry !== null) walkInventory(entry, entryPath, output);
}
}
}
function inventoryEntry(path: string): MigrationInventoryEntry {
const inventoryOnly = path === 'connector' || path.startsWith('connector.');
const requiresDecision =
path.endsWith('.kickstart_template') || path.endsWith('.kickstartTemplate');
return {
path,
disposition: inventoryOnly
? 'inventory-only'
: requiresDecision
? 'requires-decision'
: 'candidate',
};
}
function blockPresentEmptyV1Fields(
raw: Record<string, unknown>,
fleetHost: string | undefined,
blockers: MigrationBlocker[],
): void {
const tmux = record(raw.tmux);
blockPresentEmptyField(tmux, ['holder_session', 'holderSession'], 'tmux', blockers);
const defaults = record(raw.defaults);
blockPresentEmptyField(defaults, ['working_directory', 'workingDirectory'], 'defaults', blockers);
for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
blockPresentEmptyField(
record(runtimeValue),
['reset_command', 'resetCommand'],
`runtimes.${runtimeName}`,
blockers,
);
}
for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
const agent = record(agentValue);
if (classifyAgentLocality(agent, fleetHost) === 'remote') continue;
blockPresentEmptyField(
agent,
['alias', 'class', 'tool_policy', 'toolPolicy'],
`agents[${index}]`,
blockers,
);
blockPresentEmptyField(
record(agentValue),
['working_directory', 'workingDirectory'],
`agents[${index}]`,
blockers,
);
}
}
function blockPresentEmptyField(
value: Record<string, unknown>,
aliases: readonly string[],
path: string,
blockers: MigrationBlocker[],
): void {
for (const field of aliases) {
if (Object.hasOwn(value, field) && trimmedString(value[field]) === '') {
blockers.push(
blocker(
'empty-v1-field-disposition-required',
`${path}.${field}`,
'An explicit empty v1 value cannot be silently replaced by a migration default.',
),
);
}
}
}
function blockLossyV1StringNormalization(
raw: Record<string, unknown>,
fleetHost: string | undefined,
blockers: MigrationBlocker[],
): void {
const tmux = record(raw.tmux);
blockPaddedV1Field(tmux, ['holder_session', 'holderSession'], 'tmux', blockers);
const defaults = record(raw.defaults);
blockPaddedV1Field(defaults, ['working_directory', 'workingDirectory'], 'defaults', blockers);
for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
blockPaddedV1Field(
record(runtimeValue),
['reset_command', 'resetCommand'],
`runtimes.${runtimeName}`,
blockers,
);
}
for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
const agent = record(agentValue);
if (classifyAgentLocality(agent, fleetHost) === 'remote') continue;
blockPaddedV1Field(
agent,
['alias', 'tool_policy', 'toolPolicy', 'working_directory', 'workingDirectory'],
`agents[${index}]`,
blockers,
);
}
}
function blockPaddedV1Field(
value: Record<string, unknown>,
fields: readonly string[],
path: string,
blockers: MigrationBlocker[],
): void {
for (const field of fields) {
const source = exactStringValue(value[field]);
if (source !== undefined && source !== source.trim()) {
blockers.push(
blocker(
'lossy-v1-string-normalization',
`${path}.${field}`,
'A whitespace-padded v1 value cannot be silently normalized by the v2 compiler.',
),
);
}
}
}
function validateV1FieldCatalog(raw: Record<string, unknown>, blockers: MigrationBlocker[]): void {
rejectUnknownKeys(raw, ROOT_FIELDS, 'root', blockers);
requireObjectWhenPresent(raw.tmux, 'tmux', blockers);
requireObjectWhenPresent(raw.defaults, 'defaults', blockers);
requireObjectWhenPresent(raw.runtimes, 'runtimes', blockers);
const tmux = record(raw.tmux);
rejectUnknownKeys(tmux, TMUX_FIELDS, 'tmux', blockers);
rejectOptionalStringFields(tmux, [...TMUX_FIELDS], 'tmux', blockers);
rejectSynonymCollision(tmux, 'socket_name', 'socketName', 'tmux', blockers);
rejectSynonymCollision(tmux, 'holder_session', 'holderSession', 'tmux', blockers);
const defaults = record(raw.defaults);
rejectUnknownKeys(defaults, DEFAULT_FIELDS, 'defaults', blockers);
rejectOptionalStringFields(defaults, [...DEFAULT_FIELDS], 'defaults', blockers, true);
rejectSynonymCollision(defaults, 'working_directory', 'workingDirectory', 'defaults', blockers);
for (const [runtimeName, runtimeValue] of Object.entries(record(raw.runtimes))) {
requireObjectWhenPresent(runtimeValue, `runtimes.${runtimeName}`, blockers);
const runtime = record(runtimeValue);
rejectUnknownKeys(runtime, RUNTIME_FIELDS, `runtimes.${runtimeName}`, blockers);
rejectOptionalStringFields(runtime, [...RUNTIME_FIELDS], `runtimes.${runtimeName}`, blockers);
rejectSynonymCollision(
runtime,
'reset_command',
'resetCommand',
`runtimes.${runtimeName}`,
blockers,
);
}
const names = new Set<string>();
for (const [index, agentValue] of (Array.isArray(raw.agents) ? raw.agents : []).entries()) {
requireObjectWhenPresent(agentValue, `agents[${index}]`, blockers);
const agent = record(agentValue);
const path = `agents[${index}]`;
rejectUnknownKeys(agent, AGENT_FIELDS, path, blockers);
rejectOptionalStringFields(
agent,
[
'name',
'alias',
'provider',
'runtime',
'class',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'reasoning_level',
'reasoningLevel',
'tool_policy',
'toolPolicy',
'kickstart_template',
'kickstartTemplate',
],
path,
blockers,
);
const name = exactStringValue(agent.name);
if (!name || !AGENT_NAME_PATTERN.test(name)) {
blockers.push(
blocker('invalid-v1-field-type', `${path}.name`, 'A valid agent name is required.'),
);
}
if (exactStringValue(agent.runtime) === undefined) {
blockers.push(
blocker('invalid-v1-field-type', `${path}.runtime`, 'Agent runtime is required.'),
);
}
const resetBetweenTasks = agent.reset_between_tasks ?? agent.resetBetweenTasks;
if (resetBetweenTasks !== undefined && typeof resetBetweenTasks !== 'boolean') {
blockers.push(
blocker(
'invalid-v1-field-type',
`${path}.reset_between_tasks`,
'reset_between_tasks must be boolean when present.',
),
);
}
const persistentPersona = agent.persistent_persona ?? agent.persistentPersona;
if (
persistentPersona !== undefined &&
typeof persistentPersona !== 'boolean' &&
typeof persistentPersona !== 'string'
) {
blockers.push(
blocker(
'invalid-v1-field-type',
`${path}.persistent_persona`,
'persistent_persona must be boolean or string when present.',
),
);
}
for (const key of REMOTE_AGENT_INVENTORY_FIELDS) {
const target = agent[key];
if (target !== undefined && typeof target !== 'string') {
blockers.push(
blocker(
'invalid-v1-field-type',
`${path}.${key}`,
'Remote inventory field must be a string.',
),
);
} else if (typeof target === 'string' && !V1_REMOTE_TARGET_PATTERNS[key].test(target)) {
blockers.push(
blocker(
'invalid-v1-targeting-characters',
`${path}.${key}`,
'Remote inventory field contains unsupported targeting characters.',
name,
),
);
}
}
for (const [snake, camel] of [
['working_directory', 'workingDirectory'],
['model_hint', 'modelHint'],
['reasoning_level', 'reasoningLevel'],
['tool_policy', 'toolPolicy'],
['persistent_persona', 'persistentPersona'],
['reset_between_tasks', 'resetBetweenTasks'],
['kickstart_template', 'kickstartTemplate'],
] as const) {
rejectSynonymCollision(agent, snake, camel, path, blockers);
}
if (name && names.has(name)) {
blockers.push(
blocker('duplicate-agent-name', `${path}.name`, 'Agent name is duplicated.', name),
);
}
if (name) names.add(name);
}
requireObjectWhenPresent(raw.connector, 'connector', blockers);
validateConnector(record(raw.connector), raw.connector !== undefined, blockers);
}
function validateConnector(
connector: Record<string, unknown>,
present: boolean,
blockers: MigrationBlocker[],
): void {
if (!present) return;
rejectUnknownKeys(connector, new Set(['kind', 'matrix', 'discord']), 'connector', blockers);
const kind = typeof connector.kind === 'string' ? connector.kind : undefined;
if (!kind || !['tmux', 'discord', 'matrix'].includes(kind)) {
blockers.push(blocker('invalid-connector', 'connector.kind', 'Unsupported connector kind.'));
}
if (kind === 'tmux') {
if (connector.matrix !== undefined) {
blockers.push(
blocker(
'invalid-connector',
'connector.matrix',
'Tmux connector must not define Matrix connector data.',
),
);
}
if (connector.discord !== undefined) {
blockers.push(
blocker(
'invalid-connector',
'connector.discord',
'Tmux connector must not define Discord connector data.',
),
);
}
}
if (kind === 'discord' && connector.matrix !== undefined) {
blockers.push(
blocker(
'invalid-connector',
'connector.matrix',
'Discord connector must not define Matrix connector data.',
),
);
}
if (kind === 'matrix' && connector.discord !== undefined) {
blockers.push(
blocker(
'invalid-connector',
'connector.discord',
'Matrix connector must not define Discord connector data.',
),
);
}
if (kind === 'matrix' && connector.matrix === undefined) {
blockers.push(
blocker('invalid-connector', 'connector.matrix', 'Matrix connector data is required.'),
);
}
if (kind === 'discord' && connector.discord === undefined) {
blockers.push(
blocker('invalid-connector', 'connector.discord', 'Discord connector data is required.'),
);
}
if (connector.matrix !== undefined) {
requireObjectWhenPresent(connector.matrix, 'connector.matrix', blockers);
const matrix = record(connector.matrix);
rejectUnknownKeys(
matrix,
new Set(['homeserver_url', 'user_id', 'room_id']),
'connector.matrix',
blockers,
);
rejectMissingStringFields(
matrix,
['homeserver_url', 'user_id', 'room_id'],
'connector.matrix',
blockers,
);
}
if (connector.discord !== undefined) {
requireObjectWhenPresent(connector.discord, 'connector.discord', blockers);
const discord = record(connector.discord);
rejectUnknownKeys(discord, new Set(['channel_id']), 'connector.discord', blockers);
rejectMissingStringFields(discord, ['channel_id'], 'connector.discord', blockers);
}
}
function requireObjectWhenPresent(
value: unknown,
path: string,
blockers: MigrationBlocker[],
): void {
if (
value !== undefined &&
(typeof value !== 'object' || value === null || Array.isArray(value))
) {
blockers.push(blocker('invalid-v1-field-type', path, 'Field must be an object when present.'));
}
}
function rejectOptionalStringFields(
value: Record<string, unknown>,
fields: readonly string[],
path: string,
blockers: MigrationBlocker[],
allowYamlTilde = false,
): void {
for (const field of fields) {
const requested = value[field];
if (
requested !== undefined &&
typeof requested !== 'string' &&
!(allowYamlTilde && requested === null)
) {
blockers.push(
blocker(
'invalid-v1-field-type',
`${path}.${field}`,
'Field must be a string when present.',
),
);
}
}
}
function rejectMissingStringFields(
value: Record<string, unknown>,
required: readonly string[],
path: string,
blockers: MigrationBlocker[],
): void {
for (const key of required) {
if (stringValue(value[key]) === undefined) {
blockers.push(
blocker(
'invalid-v1-field-type',
`${path}.${key}`,
'Required inventory field must be a non-empty string.',
),
);
}
}
}
function rejectUnknownKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
path: string,
blockers: MigrationBlocker[],
): void {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
blockers.push(
blocker(
'unsupported-v1-field',
path === 'root' ? key : `${path}.${key}`,
'Field is not declared by the shipped v1 contract.',
),
);
}
}
}
function rejectSynonymCollision(
value: Record<string, unknown>,
snake: string,
camel: string,
path: string,
blockers: MigrationBlocker[],
): void {
if (value[snake] !== undefined && value[camel] !== undefined) {
blockers.push(
blocker(
'v1-synonym-collision',
`${path}.${snake}`,
`Both ${snake} and ${camel} are present; precedence is not inferred.`,
),
);
}
}
function assertV1Root(raw: Record<string, unknown>, blockers: MigrationBlocker[]): void {
if (raw.version !== 1)
blockers.push(blocker('invalid-v1-version', 'version', 'Version must be 1.'));
if (raw.transport !== 'tmux') {
blockers.push(blocker('invalid-v1-transport', 'transport', 'Transport must be tmux.'));
}
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
blockers.push(blocker('invalid-v1-agents', 'agents', 'At least one v1 agent is required.'));
}
}
function normalizeRuntimes(
raw: Record<string, unknown>,
blockers: MigrationBlocker[],
): Record<string, { reset_command: string }> {
const result: Record<string, { reset_command: string }> = Object.fromEntries(
Object.entries(V1_RUNTIME_RESETS).map(([name, resetCommand]) => [
name,
{ reset_command: resetCommand },
]),
);
for (const [name, value] of Object.entries(raw)) {
if (!isRuntime(name)) {
blockers.push(blocker('unsupported-runtime', `runtimes.${name}`, 'Unsupported v2 runtime.'));
continue;
}
const runtime = record(value);
const resetCommand =
exactStringValue(runtime.reset_command) ?? exactStringValue(runtime.resetCommand) ?? '/clear';
result[name] = { reset_command: resetCommand };
}
return result;
}
function resolveClass(
requested: string,
disposition: V1ClassDisposition | undefined,
path: string,
agent: string,
blockers: MigrationBlocker[],
): string | undefined {
const canonical = canonicalizeRoleClass(requested).canonicalClass;
if (AUTOMATIC_CLASSES.has(canonical)) {
if (disposition !== undefined) {
blockers.push(
blocker(
'redundant-class-disposition',
path,
'Automatically canonical classes must not carry a competing disposition.',
agent,
),
);
}
return canonical;
}
if (!disposition) {
blockers.push(
blocker(
'class-disposition-required',
path,
'Noncanonical class requires explicit preserve or replacement disposition.',
agent,
),
);
return undefined;
}
if (disposition.action === 'preserve') {
if (disposition.className !== undefined && disposition.className !== requested) {
blockers.push(
blocker(
'invalid-class-disposition',
path,
'Preserve disposition cannot change the requested class.',
agent,
),
);
return undefined;
}
return requested;
}
if (!disposition.className || disposition.className === requested) {
blockers.push(
blocker(
'invalid-class-disposition',
path,
'Replace disposition requires a different non-empty class.',
agent,
),
);
return undefined;
}
return canonicalizeRoleClass(disposition.className).canonicalClass;
}
function resolveToolPolicy(
requested: string | undefined,
disposition: V1ClassDisposition | undefined,
path: string,
agent: string,
blockers: MigrationBlocker[],
): string | undefined {
if (requested === undefined) {
if (disposition?.action !== 'replace' || !disposition.className) {
blockers.push(
blocker(
'tool-policy-disposition-required',
path,
'Missing v1 tool_policy requires an explicit replacement; class is not inferred.',
agent,
),
);
return undefined;
}
return canonicalizeRoleClass(disposition.className).canonicalClass;
}
return resolveClass(requested, disposition, path, agent, blockers);
}
function resolveLifecycle(
observation: V1MigrationObservation | undefined,
path: string,
agent: string,
blockers: MigrationBlocker[],
): RosterV2DesiredState | undefined {
if (!observation) {
blockers.push(
blocker(
'missing-lifecycle-observation',
`${path}.lifecycle`,
'Explicit systemd and tmux observation is required.',
agent,
),
);
return undefined;
}
if (observation.systemd === 'active' && observation.tmux === 'present') return 'running';
if (observation.systemd === 'inactive' && observation.tmux === 'missing') return 'stopped';
blockers.push(
blocker(
'ambiguous-lifecycle-observation',
`${path}.lifecycle`,
'Systemd and tmux evidence is unknown or contradictory.',
agent,
),
);
return undefined;
}
function generatedValues(
roster: FleetRosterV2,
agent: FleetRosterV2['agents'][number],
): Readonly<Record<string, string>> {
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
MOSAIC_AGENT_REASONING: agent.reasoning,
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
};
}
function safeEnvironmentDiagnostic(error: unknown): string {
if (
typeof error === 'object' &&
error !== null &&
'diagnostic' in error &&
typeof error.diagnostic === 'object' &&
error.diagnostic !== null
) {
const diagnostic = error.diagnostic as Partial<AgentEnvironmentDiagnostic>;
return `code=${diagnostic.code ?? 'unknown'} key=${diagnostic.key ?? '(unknown)'} sha256=${diagnostic.sha256 ?? sha256('')}`;
}
return 'Environment projection preflight failed without publishable detail.';
}
function parseClassDisposition(value: unknown, path: string): V1ClassDisposition | undefined {
if (value === undefined) return undefined;
const disposition = requireRecord(value, path);
assertOnlyKeys(disposition, CLASS_DISPOSITION_FIELDS, path);
if (disposition.action !== 'preserve' && disposition.action !== 'replace') {
throw new Error(`${path}.action must be preserve or replace.`);
}
const className = optionalNonEmptyString(disposition.className, `${path}.className`);
if (disposition.action === 'replace' && className === undefined) {
throw new Error(`${path}.className is required for replacement.`);
}
return { action: disposition.action, ...(className ? { className } : {}) };
}
function requireRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${path} must be an object.`);
}
return value as Record<string, unknown>;
}
function assertOnlyKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
path: string,
): void {
const unknown = Object.keys(value).find((key) => !allowed.has(key));
if (unknown !== undefined) throw new Error(`${path}.${unknown} is not supported.`);
}
function requiredNonEmptyString(value: unknown, path: string): string {
const normalized = stringValue(value);
if (normalized === undefined) throw new Error(`${path} must be a non-empty string.`);
return normalized;
}
function optionalNonEmptyString(value: unknown, path: string): string | undefined {
if (value === undefined) return undefined;
return requiredNonEmptyString(value, path);
}
function blocker(code: string, path: string, detail: string, agent?: string): MigrationBlocker {
return { code, path, ...(agent ? { agent } : {}), detail };
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function exactStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function trimmedString(value: unknown): string | undefined {
return typeof value === 'string' ? value.trim() : undefined;
}
function stringValue(value: unknown): string | undefined {
const trimmed = trimmedString(value);
return trimmed ? trimmed : undefined;
}
function booleanValue(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
function isRuntime(value: string | undefined): value is RosterV2RuntimeName {
return value === 'claude' || value === 'codex' || value === 'opencode' || value === 'pi';
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}