658 lines
21 KiB
TypeScript
658 lines
21 KiB
TypeScript
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');
|
|
});
|
|
});
|