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> = 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 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; } export interface V1ToV2MigrationPreviewOptions { readonly source: string; readonly sourcePath?: string; readonly decisions: V1ToV2MigrationDecisions; readonly observations: Readonly>; 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>; } export interface ValidateShippedFleetMigrationDispositionsOptions extends ValidateShippedFleetArtifactDispositionsOptions { readonly v1Previews: Readonly>; } /** 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 = {}; 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> { const root = requireRecord(value, 'observations'); const observations: Record = {}; 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 { 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, 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 declaredSocketName = hasDeclaredSnakeSocket ? trimmedString(tmux.socket_name) : hasDeclaredCamelSocket ? trimmedString(tmux.socketName) : undefined; const socketName = hasDeclaredSocket ? declaredSocketName : decisions.socketName; if ( hasDeclaredSocket && decisions.socketName !== undefined && decisions.socketName !== declaredSocketName ) { 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[] = []; for (let index = 0; index < rawAgents.length; index += 1) { const rawAgent = record(rawAgents[index]); const name = stringValue(rawAgent.name); const path = `agents[${index}]`; if (!name) { blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.')); continue; } 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 agentSocket = stringValue(rawAgent.socket); if (agentSocket && (!socketName || agentSocket !== socketName)) { blockers.push( blocker( 'agent-socket-disposition-required', `${path}.socket`, 'Local agent socket must match the canonical v2 fleet socket or receive an explicit future disposition.', 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 = stringValue(rawAgent.runtime); if (!isRuntime(runtime)) { blockers.push( blocker('unsupported-runtime', `${path}.runtime`, 'Runtime must be supported.', name), ); } const requestedClass = stringValue(rawAgent.class) || 'worker'; const className = resolveClass( requestedClass, decision.classDisposition, `${path}.class`, name, blockers, ); const requestedToolPolicy = stringValue(rawAgent.tool_policy) || stringValue(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: stringValue(rawAgent.alias) || name, class: className, runtime, provider: decision.provider, model: decision.model, reasoning: decision.reasoning, tool_policy: toolPolicy, working_directory: stringValue(rawAgent.working_directory) || stringValue(rawAgent.workingDirectory) || stringValue(defaults.working_directory) || stringValue(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) => stringValue(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 (socketName === undefined) { blockers.push( blocker( 'named-socket-disposition-required', 'tmux.socket_name', 'A socket decision is required only when both supported v1 socket fields are absent.', ), ); } 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 && socketName !== undefined && decisions.defaultRuntime) { const defaultRuntime = decisions.defaultRuntime; try { canonicalRoster = parseRosterV2( YAML.stringify({ version: 2, generation: decisions.generation, transport: 'tmux', tmux: { socket_name: socketName, holder_session: stringValue(tmux.holder_session) || stringValue(tmux.holderSession) || '_holder', }, defaults: { working_directory: stringValue(defaults.working_directory) || stringValue(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 { 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 { 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, fleetHost: string | undefined, ): AgentLocality { const host = stringValue(agent.host); const ssh = stringValue(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) : undefined; if (!sshHost || sshHost !== host) return 'ambiguous'; } return host === fleetHost ? 'local' : 'remote'; } function inventorySource( value: Record, 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)) { 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, 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()) { blockPresentEmptyField(record(agentValue), ['alias'], `agents[${index}]`, blockers); blockPresentEmptyField( record(agentValue), ['working_directory', 'workingDirectory'], `agents[${index}]`, blockers, ); } } function blockPresentEmptyField( value: Record, 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 validateV1FieldCatalog(raw: Record, 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(); 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 = stringValue(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 (stringValue(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) { if (agent[key] !== undefined && stringValue(agent[key]) === undefined) { blockers.push( blocker( 'invalid-v1-field-type', `${path}.${key}`, 'Remote inventory field must be a non-empty string.', ), ); } } 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, present: boolean, blockers: MigrationBlocker[], ): void { if (!present) return; rejectUnknownKeys(connector, new Set(['kind', 'matrix', 'discord']), 'connector', blockers); const kind = stringValue(connector.kind); if (!kind || !['tmux', 'discord', 'matrix'].includes(kind)) { blockers.push(blocker('invalid-connector', 'connector.kind', 'Unsupported connector kind.')); } 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, 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, 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, allowed: ReadonlySet, 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, 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, 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, blockers: MigrationBlocker[], ): Record { const result: Record = 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 = stringValue(runtime.reset_command) || stringValue(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 (canonical !== requested || 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> { 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; 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 { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new Error(`${path} must be an object.`); } return value as Record; } function assertOnlyKeys( value: Record, allowed: ReadonlySet, 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 { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; } 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'); }