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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user