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