Files
stack/packages/mosaic/src/fleet/fleet-agent-crud.spec.ts
jason.woltje bc5e73629e
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(fleet): add generation-guarded agent CRUD (#773)
2026-07-15 12:03:05 +00:00

221 lines
7.4 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
FleetAgentMutationError,
executeFleetAgentMutation,
planFleetAgentMutation,
type FleetAgentMutationRequest,
} from './fleet-agent-crud.js';
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';
const roster = parseRosterV2(`
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
`);
function createRequest(): FleetAgentMutationRequest {
return {
operation: 'create',
expectedGeneration: 7,
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> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
const rolesDir = join(cleanup, 'roles');
const overrideDir = join(cleanup, 'roles.local');
await mkdir(rolesDir, { recursive: true });
await mkdir(overrideDir, { recursive: true });
for (const klass of ['orchestrator', 'code']) {
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
}
return { rolesDir, overrideDir };
}
describe('fleet agent CRUD plan', (): void => {
it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
const plan = planFleetAgentMutation(roster, createRequest());
expect(plan).toMatchObject({
operation: 'create',
currentGeneration: 7,
nextGeneration: 8,
changed: true,
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
});
});
it('rejects a stale generation before proposing effects', (): void => {
expect((): void => {
planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
}).toThrow(FleetAgentMutationError);
});
it('treats an equivalent retry as an idempotent no-op', (): void => {
const created = planFleetAgentMutation(roster, createRequest()).roster;
const retried = planFleetAgentMutation(created, {
...createRequest(),
expectedGeneration: created.generation,
});
expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
});
});
describe('fleet agent CRUD execution', (): void => {
it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, 'before\n', { mode: 0o600 });
const result = await executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
dryRun: true,
});
expect(result.applied).toBe(false);
expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
});
it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });
await expect(
executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
}),
).rejects.toMatchObject({ code: 'concurrent-mutation' });
expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
});
it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentEnvDir = join(home, 'fleet', 'agents');
const created = planFleetAgentMutation(roster, createRequest()).roster;
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
mode: 0o600,
});
const result = await executeFleetAgentMutation({
roster: created,
request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
mosaicHome: home,
rosterPath,
agentEnvDir,
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
});
expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
);
});
it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
const result = await executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
projectionApplier: async (): Promise<void> => {
throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
},
});
expect(result).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(JSON.stringify(result)).not.toContain('must-not-leak');
expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
});
});