398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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<unknown>;
|
|
}
|
|
|
|
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<FleetAgentMutationResult> {
|
|
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<readonly PreparedAgentEnvironmentProjection[]> {
|
|
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<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,
|
|
};
|
|
}
|
|
|
|
async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
|
|
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<void>> {
|
|
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<void> => {
|
|
await unlink(lockPath).catch((): void => {});
|
|
};
|
|
}
|