feat(fleet): add generation-guarded agent CRUD (#773)
This commit was merged in pull request #773.
This commit is contained in:
397
packages/mosaic/src/fleet/fleet-agent-crud.ts
Normal file
397
packages/mosaic/src/fleet/fleet-agent-crud.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
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 => {});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user