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