1507 lines
50 KiB
TypeScript
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');
|
|
}
|