import { open, readFile, unlink } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { applyPreparedAgentEnvironmentProjection, prepareAgentGeneratedProjectionDeletion, prepareAgentEnvironmentProjection, type PreparedAgentEnvironmentProjection, writeManagedFleetRoster, } from './generated-env-boundary.js'; import { parseRosterV2, renderRosterV2Yaml, validateRosterV2Semantics, type FleetRosterV2, type FleetRosterV2Agent, type FleetRosterV2Launch, type FleetRosterV2Lifecycle, type RosterV2ReasoningLevel, type RosterV2RuntimeName, } from './roster-v2.js'; export type FleetAgentMutationOperation = 'create' | 'update' | 'delete'; export interface FleetAgentMutationAgent { readonly name: string; readonly alias: string; readonly className: string; readonly runtime: RosterV2RuntimeName; readonly provider: string; readonly model: string; readonly reasoning: RosterV2ReasoningLevel; readonly toolPolicy: string; readonly workingDirectory: string; readonly persistentPersona: boolean; readonly resetBetweenTasks: boolean; readonly launch: FleetRosterV2Launch; } export interface FleetAgentMutationRequest { readonly operation: FleetAgentMutationOperation; readonly expectedGeneration: number; readonly name?: string; readonly agent?: FleetAgentMutationAgent; readonly persistedStart?: boolean; } export interface FleetAgentMutationPlan { readonly operation: FleetAgentMutationOperation; readonly currentGeneration: number; readonly nextGeneration: number; readonly changed: boolean; readonly roster: FleetRosterV2; readonly agent?: FleetRosterV2Agent; } export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed'; export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete'; export interface FleetAgentMutationResult { /** True only when every requested roster and projection write completed. */ readonly applied: boolean; /** Whether the authoritative roster changed on disk. */ readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState; /** Whether derived projections were skipped, complete, or require recovery. */ readonly projections: FleetAgentMutationProjectionState; readonly plan: FleetAgentMutationPlan; readonly recovery?: FleetAgentMutationRecovery; } export interface FleetAgentMutationRecovery { readonly code: 'projection-apply-failed'; readonly rosterPath: string; readonly action: 'regenerate-projections-from-roster'; } export interface FleetAgentMutationOptions { readonly roster: FleetRosterV2; readonly request: FleetAgentMutationRequest; readonly mosaicHome: string; readonly rosterPath: string; readonly agentEnvDir: string; readonly rolesDir: string; readonly overrideDir: string; readonly dryRun?: boolean; readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise; } export class FleetAgentMutationError extends Error { constructor( readonly code: | 'stale-generation' | 'agent-conflict' | 'agent-not-found' | 'invalid-request' | 'concurrent-mutation' | 'projection-apply-failed', message: string, ) { super(message); this.name = FleetAgentMutationError.name; } } /** Plans a generation-guarded desired-state mutation without writing any file. */ export function planFleetAgentMutation( roster: FleetRosterV2, request: FleetAgentMutationRequest, ): FleetAgentMutationPlan { if (request.expectedGeneration !== roster.generation) { throw new FleetAgentMutationError( 'stale-generation', `Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`, ); } if (request.operation === 'create') return planCreate(roster, request); if (request.operation === 'update') return planUpdate(roster, request); return planDelete(roster, request); } /** * Validates the complete proposed roster and its deterministic projections before * changing the roster authority. The only post-roster write is a derived projection. */ export async function executeFleetAgentMutation( options: FleetAgentMutationOptions, ): Promise { const plan = planFleetAgentMutation(options.roster, options.request); await validateRosterV2Semantics(plan.roster, { rolesDir: options.rolesDir, overrideDir: options.overrideDir, }); const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir); if (plan.operation === 'delete' && plan.agent) { // Delete validates only the exact generated projection. Local overrides, // legacy input, and quarantine records are retained operator state. await prepareAgentGeneratedProjectionDeletion({ mosaicHome: options.mosaicHome, agentEnvDir: options.agentEnvDir, agentName: plan.agent.name, }); } if (options.dryRun === true || !plan.changed) { return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan }; } const lockPath = `${options.rosterPath}.mutation.lock`; const release = await acquireMutationLock(lockPath); try { const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml'); if (persisted.generation !== options.roster.generation) { throw new FleetAgentMutationError( 'stale-generation', `Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`, ); } await writeManagedFleetRoster( options.mosaicHome, options.rosterPath, renderRosterV2Yaml(plan.roster), ); const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection; try { for (const projection of prepared) await apply(projection); if (plan.operation === 'delete' && plan.agent) { await prepareAgentGeneratedProjectionDeletion({ mosaicHome: options.mosaicHome, agentEnvDir: options.agentEnvDir, agentName: plan.agent.name, }); await removeGeneratedProjection(options.agentEnvDir, plan.agent.name); } } catch { throw new FleetAgentMutationError( 'projection-apply-failed', `Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`, ); } return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan }; } catch (error: unknown) { if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') { return { applied: false, authoritativeRoster: 'committed', projections: 'incomplete', plan, recovery: { code: 'projection-apply-failed', rosterPath: options.rosterPath, action: 'regenerate-projections-from-roster', }, }; } throw error; } finally { await release(); } } function planCreate( roster: FleetRosterV2, request: FleetAgentMutationRequest, ): FleetAgentMutationPlan { if (!request.agent) throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.'); const existing = roster.agents.find( (agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name, ); const created = toRosterAgent(request.agent, request.persistedStart === true); if (existing) { if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing); throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`); } return changedPlan(roster, request.operation, [...roster.agents, created], created); } function planUpdate( roster: FleetRosterV2, request: FleetAgentMutationRequest, ): FleetAgentMutationPlan { if (!request.name || !request.agent) { throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.'); } const existing = roster.agents.find( (agent: FleetRosterV2Agent): boolean => agent.name === request.name, ); if (!existing) throw new FleetAgentMutationError( 'agent-not-found', `Agent "${request.name}" is not in roster.`, ); if (request.agent.name !== request.name) { throw new FleetAgentMutationError( 'invalid-request', 'Agent names are immutable during update.', ); } const updated = { ...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'), lifecycle: existing.lifecycle, }; if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing); return changedPlan( roster, request.operation, roster.agents.map( (agent: FleetRosterV2Agent): FleetRosterV2Agent => agent.name === request.name ? updated : agent, ), updated, ); } function planDelete( roster: FleetRosterV2, request: FleetAgentMutationRequest, ): FleetAgentMutationPlan { if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.'); const existing = roster.agents.find( (agent: FleetRosterV2Agent): boolean => agent.name === request.name, ); if (!existing) return unchangedPlan(roster, request.operation); return changedPlan( roster, request.operation, roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name), existing, ); } function toRosterAgent( input: FleetAgentMutationAgent, persistedStart: boolean, ): FleetRosterV2Agent { const lifecycle: FleetRosterV2Lifecycle = { enabled: true, desiredState: persistedStart ? 'running' : 'stopped', }; return { ...input, lifecycle }; } function changedPlan( roster: FleetRosterV2, operation: FleetAgentMutationOperation, agents: readonly FleetRosterV2Agent[], agent?: FleetRosterV2Agent, ): FleetAgentMutationPlan { const proposed = { ...roster, generation: roster.generation + 1, agents }; const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml'); return { operation, currentGeneration: roster.generation, nextGeneration: normalized.generation, changed: true, roster: normalized, ...(agent ? { agent } : {}), }; } function unchangedPlan( roster: FleetRosterV2, operation: FleetAgentMutationOperation, agent?: FleetRosterV2Agent, ): FleetAgentMutationPlan { return { operation, currentGeneration: roster.generation, nextGeneration: roster.generation, changed: false, roster, ...(agent ? { agent } : {}), }; } function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean { return ( left.name === right.name && left.alias === right.alias && left.className === right.className && left.runtime === right.runtime && left.provider === right.provider && left.model === right.model && left.reasoning === right.reasoning && left.toolPolicy === right.toolPolicy && left.workingDirectory === right.workingDirectory && left.persistentPersona === right.persistentPersona && left.resetBetweenTasks === right.resetBetweenTasks && left.launch.yolo === right.launch.yolo && left.lifecycle.enabled === right.lifecycle.enabled && left.lifecycle.desiredState === right.lifecycle.desiredState ); } async function prepareProjections( roster: FleetRosterV2, mosaicHome: string, agentEnvDir: string, ): Promise { const projections: PreparedAgentEnvironmentProjection[] = []; for (const agent of roster.agents) { projections.push( await prepareAgentEnvironmentProjection({ mosaicHome, agentEnvDir, agentName: agent.name, generated: generatedValues(roster, agent), }), ); } return projections; } function generatedValues( roster: FleetRosterV2, agent: FleetRosterV2Agent, ): 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, }; } async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise { try { await unlink(join(agentEnvDir, `${agentName}.env.generated`)); } catch (error: unknown) { if (isMissingFile(error)) return; throw error; } } function isMissingFile(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; } async function acquireMutationLock(lockPath: string): Promise<() => Promise> { try { const handle = await open(lockPath, 'wx', 0o600); await handle.close(); } catch { throw new FleetAgentMutationError( 'concurrent-mutation', `Another roster mutation is in progress for ${dirname(lockPath)}.`, ); } return async (): Promise => { await unlink(lockPath).catch((): void => {}); }; }