393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
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));
|
|
}
|