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