feat(fleet): add generation-guarded agent CRUD (#773)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #773.
This commit is contained in:
2026-07-15 12:03:05 +00:00
parent 191efaefeb
commit bc5e73629e
10 changed files with 1866 additions and 0 deletions

View File

@@ -0,0 +1,392 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { Command } from 'commander';
import {
executeFleetAgentMutation,
FleetAgentMutationError,
type FleetAgentMutationAgent,
type FleetAgentMutationOperation,
type FleetAgentMutationOptions,
type FleetAgentMutationRequest,
type FleetAgentMutationResult,
} from '../fleet/fleet-agent-crud.js';
import {
parseRosterV2,
ROSTER_V2_REASONING_LEVELS,
ROSTER_V2_SUPPORTED_RUNTIMES,
type FleetRosterV2,
type RosterV2ReasoningLevel,
type RosterV2RuntimeName,
} from '../fleet/roster-v2.js';
export interface FleetAgentCrudCommandDeps {
readonly mosaicHome?: string;
/** Test seam for deterministic post-roster filesystem projection failures. */
readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier'];
}
interface MutationOptions {
readonly expectedGeneration: string;
readonly agent?: string;
readonly dryRun?: boolean;
readonly persistedStart?: boolean;
}
const AGENT_REQUEST_KEYS = new Set([
'name',
'alias',
'className',
'runtime',
'provider',
'model',
'reasoning',
'toolPolicy',
'workingDirectory',
'persistentPersona',
'resetBetweenTasks',
'launch',
]);
const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']);
/** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */
export function registerFleetAgentCrudCommands(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps = {},
): void {
agentCommand
.command('get <name>')
.description('Read one local roster-v2 agent as JSON')
.action(async (name: string): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
const roster = await loadRoster(agentCommand, deps);
const agent = roster.agents.find((candidate): boolean => candidate.name === name);
if (!agent)
throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`);
printJson({ generation: roster.generation, agent });
});
});
registerMutationCommand(agentCommand, deps, 'create');
registerMutationCommand(agentCommand, deps, 'update');
registerMutationCommand(agentCommand, deps, 'delete');
agentCommand
.command('plan <operation> [name]')
.description('Plan a local roster-v2 mutation without writing roster or projections')
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', 'JSON agent payload for create or update')
.option(
'--persisted-start',
'Plan create with desired_state: running without starting a runtime',
)
.action(
async (operation: string, name: string | undefined, opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
const parsedOperation = parseOperation(operation);
await executeCommand(
agentCommand,
deps,
parsedOperation,
opts,
true,
parsePlanTargetName(parsedOperation, name),
);
});
},
);
}
function registerMutationCommand(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
operation: FleetAgentMutationOperation,
): void {
const command = agentCommand
.command(
operation === 'update'
? 'update <name>'
: `${operation}${operation === 'delete' ? ' <name>' : ''}`,
)
.description(`${operation} a local roster-v2 agent without runtime actions`)
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', 'JSON agent payload for create or update')
.option('--dry-run', 'Validate and plan without writing roster or projections');
if (operation === 'create') {
command.option(
'--persisted-start',
'Persist desired_state: running without starting a runtime',
);
command.action(async (opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false);
});
});
return;
}
command.action(async (name: string, opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false, name);
});
});
}
async function executeCommand(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
operation: FleetAgentMutationOperation,
opts: MutationOptions,
forceDryRun: boolean,
name?: string,
): Promise<void> {
const mosaicHome = resolveMosaicHome(agentCommand, deps);
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
const request = buildRequest(operation, opts, name);
const result = await executeFleetAgentMutation({
roster,
request,
mosaicHome,
rosterPath,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
printJson(result);
process.exitCode = fleetAgentMutationExitCode(result);
}
/** A persisted roster with failed derived projections is a non-zero CLI outcome. */
export function fleetAgentMutationExitCode(
result: Pick<FleetAgentMutationResult, 'recovery'>,
): 0 | 1 {
return result.recovery === undefined ? 0 : 1;
}
function parsePlanTargetName(
operation: FleetAgentMutationOperation,
name: string | undefined,
): string | undefined {
if (operation === 'create') {
if (name !== undefined) {
throw new FleetAgentMutationError(
'invalid-request',
'Create plan does not accept a target name.',
);
}
return undefined;
}
if (name === undefined) {
throw new FleetAgentMutationError(
'invalid-request',
`${operation} plan requires a target agent name.`,
);
}
return name;
}
function buildRequest(
operation: FleetAgentMutationOperation,
opts: MutationOptions,
name?: string,
): FleetAgentMutationRequest {
const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration);
const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent);
if ((operation === 'create' || operation === 'update') && !agent) {
throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`);
}
return {
operation,
expectedGeneration,
...(name === undefined ? {} : { name }),
...(agent === undefined ? {} : { agent }),
...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}),
};
}
function parseExpectedGeneration(value: string): number {
const generation = Number(value);
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new FleetAgentMutationError(
'invalid-request',
'--expected-generation must be a positive safe integer.',
);
}
return generation;
}
function parseOperation(value: string): FleetAgentMutationOperation {
if (value === 'create' || value === 'update' || value === 'delete') return value;
throw new FleetAgentMutationError(
'invalid-request',
'plan operation must be create, update, or delete.',
);
}
function parseAgent(source: string): FleetAgentMutationAgent {
let value: unknown;
try {
value = JSON.parse(source);
} catch {
throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.');
}
if (!isRecord(value)) {
throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.');
}
rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent');
const runtime = requiredRuntime(value, 'runtime');
const reasoning = requiredReasoning(value, 'reasoning');
const launch = requiredRecord(value, 'launch');
rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch');
return {
name: requiredString(value, 'name'),
alias: requiredString(value, 'alias'),
className: requiredString(value, 'className'),
runtime,
provider: requiredString(value, 'provider'),
model: requiredString(value, 'model'),
reasoning,
toolPolicy: requiredString(value, 'toolPolicy'),
workingDirectory: requiredString(value, 'workingDirectory'),
persistentPersona: requiredBoolean(value, 'persistentPersona'),
resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'),
launch: { yolo: requiredBoolean(launch, 'yolo') },
};
}
async function loadRoster(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
): Promise<FleetRosterV2> {
const mosaicHome = resolveMosaicHome(agentCommand, deps);
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
}
function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string {
const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>();
return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic');
}
function resolveRosterPath(agentCommand: Command, mosaicHome: string): string {
const opts = agentCommand.optsWithGlobals<{ roster?: string }>();
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) {
throw new FleetAgentMutationError(
'invalid-request',
'Roster-v2 mutations require the canonical <mosaic-home>/fleet/roster.yaml path.',
);
}
return canonical;
}
function rejectUnknownKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
field: string,
): void {
const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some(
(key: string): boolean => !allowedKeys.has(key),
);
if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) {
throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`);
}
}
function requiredString(value: Record<string, unknown>, key: string): string {
const candidate = requiredOwnValue(value, key);
if (typeof candidate !== 'string' || candidate.length === 0) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} must be a non-empty string.`,
);
}
return candidate;
}
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
const candidate = requiredOwnValue(value, key);
if (typeof candidate !== 'boolean') {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`);
}
return candidate;
}
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
const candidate = requiredOwnValue(value, key);
if (!isRecord(candidate)) {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`);
}
return candidate;
}
function requiredRuntime(value: Record<string, unknown>, key: string): RosterV2RuntimeName {
const candidate = requiredString(value, key);
if (!isRuntime(candidate)) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} is not a supported runtime.`,
);
}
return candidate;
}
function requiredReasoning(value: Record<string, unknown>, key: string): RosterV2ReasoningLevel {
const candidate = requiredString(value, key);
if (!isReasoning(candidate)) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} is not a supported reasoning level.`,
);
}
return candidate;
}
function isRuntime(value: string): value is RosterV2RuntimeName {
return ROSTER_V2_SUPPORTED_RUNTIMES.some(
(runtime: RosterV2RuntimeName): boolean => runtime === value,
);
}
function isReasoning(value: string): value is RosterV2ReasoningLevel {
return ROSTER_V2_REASONING_LEVELS.some(
(reasoning: RosterV2ReasoningLevel): boolean => reasoning === value,
);
}
function requiredOwnValue(value: Record<string, unknown>, key: string): unknown {
if (!Object.hasOwn(value, key)) {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`);
}
return value[key];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
async function writeJsonOutcome(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (error: unknown) {
process.exitCode = 1;
printJson({
error: {
code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed',
},
});
}
}
function printJson(value: object): void {
console.log(JSON.stringify(value));
}