import { chmod, mkdir, mkdtemp, rm, 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 { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js'; import { registerFleetCommand, type CommandResult, 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: coder0 alias: Coder 0 class: code runtime: pi provider: openai model: gpt-5.6-sol reasoning: high tool_policy: code working_directory: /srv/mosaic persistent_persona: false reset_between_tasks: true lifecycle: enabled: true desired_state: stopped launch: yolo: true `; let cleanup: string | undefined; afterEach(async (): Promise => { vi.restoreAllMocks(); process.exitCode = undefined; if (cleanup) await rm(cleanup, { recursive: true, force: true }); cleanup = undefined; }); async function fleetHome(): Promise { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-')); for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) { await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 }); await chmod(join(cleanup, directory), 0o700); } await chmod(cleanup, 0o700); await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 }); await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', { mode: 0o600, }); return cleanup; } function program( mosaicHome: string, runner: FleetCommandDeps['runner'], reconcileOverrides: Partial = {}, ): Command { const result = new Command(); result.exitOverride(); registerFleetCommand(result, { mosaicHome, runner, reconcileDeps: { homeDirectory: '/home/mosaic', readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111', validateRoster: async () => undefined, prepareProjections: async () => [{ agentName: 'coder0' }], applyProjection: async () => undefined, ...reconcileOverrides, }, }); return result; } function capture(): string[] { const lines: string[] = []; vi.spyOn(console, 'log').mockImplementation((value: string): void => { lines.push(value); }); return lines; } function ownedRunner( calls: string[][], ): (command: string, args: string[]) => Promise { return async (command: string, args: string[]): Promise => { calls.push([command, ...args]); if (command === 'tmux' && args.includes('list-sessions')) { return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 }; } if (command === 'tmux' && args.includes('show-environment')) { return { stdout: 'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n', stderr: '', exitCode: 0, }; } return { stdout: '', stderr: '', exitCode: 0 }; }; } describe('mosaic fleet reconciler commands', (): void => { it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise => { const home = await fleetHome(); const calls: string[][] = []; const lines = capture(); await program(home, ownedRunner(calls)).parseAsync([ 'node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7', '--dry-run', ]); expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', lifecycle: 'not-applied', }); expect(calls).not.toContainEqual([ 'systemctl', '--user', 'start', 'mosaic-agent@coder0.service', ]); }); it('uses direct fleet status and doctor as observational JSON commands', async (): Promise => { const home = await fleetHome(); const lines = capture(); const calls: string[][] = []; const cli = program(home, ownedRunner(calls)); await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']); await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']); expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([ { applied: false, lifecycle: 'not-applied' }, { applied: false, lifecycle: 'not-applied' }, ]); expect( calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'), ).toBe(true); }); it.each(['start', 'stop', 'restart'] as const)( 'uses exact roster-owned systemd targeting for %s', async (operation: 'start' | 'stop' | 'restart'): Promise => { const home = await fleetHome(); const calls: string[][] = []; const lines = capture(); await program(home, ownedRunner(calls)).parseAsync([ 'node', 'mosaic', 'fleet', operation, 'coder0', '--expected-generation', '7', ]); expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' }); expect(calls).toContainEqual([ 'systemctl', '--user', operation, 'mosaic-agent@coder0.service', ]); expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe( false, ); }, ); it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise => { const home = await fleetHome(); const lines = capture(); await program(home, ownedRunner([]), { acquireMutationLock: async () => async () => { throw new Error('cleanup failure'); }, }).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']); expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, projections: 'complete', lifecycle: 'complete', cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' }, }); expect(process.exitCode).toBe(1); }); it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise => { const cleanupFailure = async () => async () => { throw new Error('cleanup failure'); }; const projectionHome = await fleetHome(); const projectionLines = capture(); await program(projectionHome, ownedRunner([]), { applyProjection: async () => { throw new Error('projection failure'); }, acquireMutationLock: cleanupFailure, }).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']); expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({ projections: 'incomplete', lifecycle: 'not-applied', recovery: { code: 'projection-apply-failed' }, cleanup: { code: 'lock-cleanup-failed' }, }); expect(process.exitCode).toBe(1); }); it('keeps clean effect and observational commands at zero exit', async (): Promise => { const home = await fleetHome(); const cli = program(home, ownedRunner([])); await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']); expect(process.exitCode).toBe(0); process.exitCode = undefined; await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']); expect(process.exitCode).toBe(0); }); it('rejects stale apply generations as non-zero redacted JSON', async (): Promise => { const home = await fleetHome(); const lines = capture(); await program(home, ownedRunner([])).parseAsync([ 'node', 'mosaic', 'fleet', 'apply', '--expected-generation', '6', ]); expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } }); expect(process.exitCode).toBe(1); }); });