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,657 @@
import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js';
import { registerFleetCommand, type FleetCommandDeps } from './fleet.js';
const roster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: orchestrator
alias: Orchestrator
class: orchestrator
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: orchestrator
working_directory: /srv/mosaic
persistent_persona: true
reset_between_tasks: false
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
const agent = {
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
launch: { yolo: true },
};
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-'));
const fleetDir = join(cleanup, 'fleet');
const agentsDir = join(fleetDir, 'agents');
const rolesDir = join(fleetDir, 'roles');
await mkdir(agentsDir, { recursive: true, mode: 0o700 });
await mkdir(rolesDir, { recursive: true, mode: 0o700 });
await chmod(cleanup, 0o700);
await chmod(fleetDir, 0o700);
await chmod(agentsDir, 0o700);
await chmod(rolesDir, 0o700);
await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 });
for (const className of ['orchestrator', 'code']) {
await writeFile(
join(rolesDir, `${className}.md`),
`# ${className}\n\n(\`class: ${className}\`)\n`,
{
mode: 0o600,
},
);
}
return cleanup;
}
function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { ...deps, mosaicHome });
return result;
}
function output(): { lines: string[] } {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return { lines };
}
describe('mosaic fleet local roster mutations', (): void => {
it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => {
expect(
fleetAgentMutationExitCode({
recovery: {
code: 'projection-apply-failed',
rosterPath: '/private/fleet/roster.yaml',
action: 'regenerate-projections-from-roster',
},
}),
).toBe(1);
});
it('returns zero for a mutation result without recovery work', (): void => {
expect(fleetAgentMutationExitCode({})).toBe(0);
});
it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise<void> => {
const home = await fleetHome();
const root = program(home);
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
);
});
it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise<void> => {
const home = await fleetHome();
const root = new Command();
root.exitOverride();
registerFleetCommand(root, { mosaicHome: home });
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
);
});
it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
const root = new Command();
root.exitOverride();
registerFleetCommand(root, { mosaicHome: home });
await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
generation: 7,
agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } },
});
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
});
it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']);
expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } });
expect(process.exitCode).toBe(1);
});
it('prints a deterministic create plan without changing roster or generated projections', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
plan: {
operation: 'create',
currentGeneration: 7,
nextGeneration: 8,
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
},
});
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
});
it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
const updatedCoder = { ...agent, alias: 'Coder Updated' };
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
const beforePlans = await readFile(rosterPath, 'utf8');
const beforeOrchestratorProjection = await readFile(
join(home, 'fleet', 'agents', 'orchestrator.env.generated'),
'utf8',
);
captured.lines.length = 0;
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'update',
'coder0',
'--expected-generation',
'8',
'--agent',
JSON.stringify(updatedCoder),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'delete',
'coder0',
'--expected-generation',
'8',
]);
const plans = captured.lines.map((line: string): unknown => JSON.parse(line));
expect(plans).toMatchObject([
{ plan: { operation: 'update', agent: { name: 'coder0' } } },
{ plan: { operation: 'delete', agent: { name: 'coder0' } } },
]);
expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans);
expect(
await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'),
).toBe(beforeOrchestratorProjection);
});
it.each([
['plan create', ['fleet', 'plan', 'create'], 'top-level'],
['create', ['fleet', 'create'], 'top-level'],
['update', ['fleet', 'update', 'orchestrator'], 'top-level'],
['plan create', ['fleet', 'plan', 'create'], 'launch'],
['create', ['fleet', 'create'], 'launch'],
['update', ['fleet', 'update', 'orchestrator'], 'launch'],
])(
'rejects unknown and prototype-sensitive agent fields without mutation',
async (_operation: string, command: string[], shape: string): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
const artifactContents = new Map([
['orchestrator.env.generated', 'generated-before\n'],
['orchestrator.env.local', 'local-before\n'],
['orchestrator.env', 'legacy-before\n'],
['orchestrator.env.quarantine', 'quarantine-before\n'],
]);
for (const [filename, content] of artifactContents) {
await writeFile(join(agentsDir, filename), content, { mode: 0o600 });
}
const rejectedValue = 'must-not-appear-in-diagnostics';
const unsafeAgent = {
...agent,
name: 'orchestrator',
launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true },
...(shape === 'top-level'
? {
command: rejectedValue,
channel: rejectedValue,
secretRef: rejectedValue,
constructor: rejectedValue,
prototype: rejectedValue,
}
: {}),
};
const payload =
shape === 'top-level'
? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}')
: JSON.stringify(unsafeAgent);
await program(home).parseAsync([
'node',
'mosaic',
...command,
'--expected-generation',
'7',
'--agent',
payload,
]);
const diagnostic = captured.lines.pop() ?? '';
expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } });
expect(diagnostic).not.toContain(rejectedValue);
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
for (const [filename, content] of artifactContents) {
expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content);
}
},
);
it('rejects missing update/delete plan names and an extra create plan name', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'delete',
'--expected-generation',
'7',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'coder0',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
});
it('plans persisted start as desired state without starting a runtime', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
'--persisted-start',
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
plan: { agent: { lifecycle: { desiredState: 'running' } } },
});
});
it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home, {
projectionApplier: async (): Promise<never> => {
throw new Error('injected projection failure');
},
}).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8');
expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0');
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
});
it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', {
mode: 0o600,
});
const beforeDryRun = await Promise.all([
readFile(rosterPath, 'utf8'),
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
'--dry-run',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
});
expect(
await Promise.all([
readFile(rosterPath, 'utf8'),
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
]),
).toEqual(beforeDryRun);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
authoritativeRoster: 'committed',
projections: 'complete',
});
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]);
expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]);
expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]);
expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe(
beforeDryRun[5],
);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
});
it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
captured.lines.length = 0;
await program(home, {
projectionApplier: async (): Promise<never> => {
throw new Error('injected projection failure');
},
}).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
'MOSAIC_AGENT_NAME=coder0',
);
});
it.each(['unsafe directory permissions', 'symlinked directory'] as const)(
'rejects delete before roster mutation for %s',
async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
const beforeRoster = await readFile(rosterPath, 'utf8');
let generatedPath = join(agentsDir, 'coder0.env.generated');
if (hazard === 'unsafe directory permissions') {
await chmod(agentsDir, 0o777);
} else {
const targetDir = join(home, 'fleet', 'agents-target');
await rename(agentsDir, targetDir);
await symlink(targetDir, agentsDir, 'dir');
generatedPath = join(targetDir, 'coder0.env.generated');
}
try {
captured.lines.length = 0;
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
]);
} finally {
if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700);
}
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({
error: { code: 'mutation-failed' },
});
expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster);
expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0');
},
);
it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
const updated = { ...agent, alias: 'Coder Zero' };
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true });
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
'MOSAIC_AGENT_NAME=coder0',
);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'update',
'coder0',
'--expected-generation',
'8',
'--agent',
JSON.stringify(updated),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } },
});
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', {
mode: 0o600,
});
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'9',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } },
});
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(
'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n',
);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10');
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
});
});

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));
}

View File

@@ -82,10 +82,14 @@ describe('registerFleetCommand', () => {
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'backlog',
'create',
'delete',
'get',
'init',
'install',
'install-systemd',
'persona',
'plan',
'profile',
'provision',
'ps',
@@ -94,6 +98,7 @@ describe('registerFleetCommand', () => {
'start',
'status',
'stop',
'update',
'verify',
]);
});

View File

@@ -18,6 +18,10 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import {
applyPreparedAgentEnvironmentProjection,
@@ -73,6 +77,7 @@ export interface FleetCommandDeps {
* Tests stub this to simulate interactive or non-interactive environments.
*/
isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
}
interface RawFleetRoster {
@@ -2074,6 +2079,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
return cmd;
}