import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildAgentSendCommand, buildFleetServiceCommand, generateAgentEnv, getDefaultOperatorSourceLabel, getRosterAgent, loadFleetRoster, mergeAgentEnv, registerFleetCommand, resolveFleetPaths, type CommandRunner, } from './fleet.js'; import { registerAgentCommand } from './agent.js'; function buildProgram(): Command { const program = new Command(); program.exitOverride(); registerFleetCommand(program); registerAgentCommand(program); return program; } async function tempDir(): Promise { return mkdtemp(join(tmpdir(), 'mosaic-fleet-')); } describe('registerFleetCommand', () => { it('registers local canary fleet subcommands', () => { const program = buildProgram(); const fleet = program.commands.find((command) => command.name() === 'fleet'); expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'init', 'install', 'install-systemd', 'restart', 'start', 'status', 'stop', 'verify', ]); }); it('adds fleet-backed agent subcommands without removing existing options', () => { const program = buildProgram(); const agent = program.commands.find((command) => command.name() === 'agent'); expect(agent).toBeDefined(); expect(agent!.options.map((option) => option.long)).toContain('--list'); expect(agent!.commands.map((command) => command.name()).sort()).toEqual([ 'reset', 'roster', 'send', 'status', 'tail', ]); }); }); describe('fleet roster parsing', () => { let cleanup: string | undefined; afterEach(async () => { if (cleanup) { await rm(cleanup, { recursive: true, force: true }); cleanup = undefined; } }); it('defaults local canary rosters to the isolated mosaic-factory socket', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.yaml'); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'agents:', ' - name: canary-pi', ' runtime: pi', ' class: canary', ].join('\n'), ); const roster = await loadFleetRoster(rosterPath); expect(roster.tmux.socketName).toBe('mosaic-factory'); expect(roster.tmux.holderSession).toBe('_holder'); expect(roster.agents).toHaveLength(1); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); }); it('generates deterministic per-agent EnvironmentFile content', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.json'); await writeFile( rosterPath, JSON.stringify({ version: 1, transport: 'tmux', tmux: { socket_name: 'mosaic-factory' }, defaults: { working_directory: '/srv/mosaic' }, agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }], }), ); const roster = await loadFleetRoster(rosterPath); expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( [ 'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic', 'MOSAIC_TMUX_SOCKET=mosaic-factory', '', ].join('\n'), ); }); it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => { const generated = [ 'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_WORKDIR=/srv/new', 'MOSAIC_TMUX_SOCKET=mosaic-factory', '', ].join('\n'); const existing = [ 'MOSAIC_AGENT_NAME=old-name', 'MOSAIC_AGENT_RUNTIME=old-runtime', 'MOSAIC_AGENT_WORKDIR=/srv/old', 'MOSAIC_TMUX_SOCKET=old-socket', 'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh', '# site note', '', ].join('\n'); expect(mergeAgentEnv(generated, existing)).toBe( [ 'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_WORKDIR=/srv/new', 'MOSAIC_TMUX_SOCKET=mosaic-factory', 'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh', '# site note', '', ].join('\n'), ); }); it('rejects unknown roster fields instead of silently defaulting', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.yaml'); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'tmux:', ' socketNamee: prod-fleet', 'agents:', ' - name: canary-pi', ' runtime: pi', ].join('\n'), ); await expect(loadFleetRoster(rosterPath)).rejects.toThrow( 'Fleet roster tmux has unknown field(s): socketNamee.', ); }); it('rejects wrong-typed roster fields instead of silently defaulting', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.json'); await writeFile( rosterPath, JSON.stringify({ version: 1, transport: 'tmux', tmux: { socket_name: 123 }, defaults: { working_directory: '/srv/mosaic' }, agents: [{ name: 'canary-pi', runtime: 'pi' }], }), ); await expect(loadFleetRoster(rosterPath)).rejects.toThrow( 'Fleet roster tmux socket_name must be a string.', ); }); it('rejects wrong-typed agent fields', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.json'); await writeFile( rosterPath, JSON.stringify({ version: 1, transport: 'tmux', agents: [{ name: 'canary-pi', runtime: 42 }], }), ); await expect(loadFleetRoster(rosterPath)).rejects.toThrow( 'Fleet roster agent "canary-pi" runtime must be a string.', ); }); it('rejects duplicate agent names before install can overwrite env files', async () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.yaml'); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'agents:', ' - name: canary-pi', ' runtime: pi', ' - name: canary-pi', ' runtime: codex', ].join('\n'), ); await expect(loadFleetRoster(rosterPath)).rejects.toThrow( 'Fleet roster has duplicate agent name: canary-pi.', ); }); it('ships generic minimal and local-canary examples without site-specific defaults', async () => { const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples'); const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml')); const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8'); const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml')); expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']); expect(localCanary.tmux.socketName).toBe('mosaic-factory'); expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']); expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i); }); }); describe('fleet command construction', () => { it('builds exact systemd user commands for holder and agent operations', () => { expect(buildFleetServiceCommand('status')).toEqual([ 'systemctl', '--user', 'status', 'mosaic-tmux-holder.service', ]); expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([ 'systemctl', '--user', 'restart', 'mosaic-agent@coder0.service', ]); }); it('builds socket-scoped agent send commands', () => { const paths = resolveFleetPaths('/home/test/.config/mosaic'); expect( buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'), ).toEqual([ '/home/test/.config/mosaic/tools/tmux/agent-send.sh', '-L', 'mosaic-factory', '-S', 'operator:mosaic-cli', '-s', 'coder0', '-m', 'hello', ]); }); it('runs fleet status through injected runner without touching tmux in tests', async () => { const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: 'ok\n', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner }); await program.parseAsync(['node', 'mosaic', 'fleet', 'status']); expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]); }); it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => { const home = await tempDir(); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']); expect(calls).toEqual([ ['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'], ['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('writes init output to the explicit roster path', async () => { const home = await tempDir(); const rosterPath = join(home, 'custom', 'roster.yaml'); const frameworkRoot = resolve(process.cwd(), 'framework'); const program = new Command(); program.exitOverride(); registerFleetCommand(program, { frameworkRoot, mosaicHome: home }); try { await program.parseAsync([ 'node', 'mosaic', 'fleet', '--roster', rosterPath, 'init', '--profile', 'minimal', '--write', ]); const content = await readFile(rosterPath, 'utf8'); expect(content).toContain('name: canary-pi'); } finally { await rm(home, { recursive: true, force: true }); } }); it('refuses to overwrite an existing roster unless --force is provided', async () => { const home = await tempDir(); const rosterPath = join(home, 'custom', 'roster.yaml'); await mkdir(dirname(rosterPath), { recursive: true }); await writeFile(rosterPath, 'site-owned: true\n'); const frameworkRoot = resolve(process.cwd(), 'framework'); const program = new Command(); program.exitOverride(); registerFleetCommand(program, { frameworkRoot, mosaicHome: home }); try { await expect( program.parseAsync([ 'node', 'mosaic', 'fleet', '--roster', rosterPath, 'init', '--profile', 'minimal', '--write', ]), ).rejects.toThrow('Fleet roster already exists'); expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n'); await program.parseAsync([ 'node', 'mosaic', 'fleet', '--roster', rosterPath, 'init', '--profile', 'minimal', '--write', '--force', ]); expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi'); } finally { await rm(home, { recursive: true, force: true }); } }); it('rejects unknown init profiles instead of silently falling back', async () => { const program = new Command(); program.exitOverride(); registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') }); await expect( program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']), ).rejects.toThrow('Unsupported fleet profile'); }); it('sets process exitCode when status runner fails', async () => { const originalExitCode = process.exitCode; const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 }); const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'status']); expect(process.exitCode).toBe(3); } finally { process.exitCode = originalExitCode; stderrSpy.mockRestore(); } }); it('loads default fleet/roster.json when roster.yaml is absent', async () => { const home = await tempDir(); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.json'), JSON.stringify({ version: 1, transport: 'tmux', agents: [{ name: 'json-canary', runtime: 'pi' }], }), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']); expect(calls).toEqual([ ['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('starts the holder before agents and stops agents before the holder', async () => { const home = await tempDir(); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'start']); await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']); expect(calls).toEqual([ ['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'], ['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'], ['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'], ['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => { const home = await tempDir(); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex', ' - name: reviewer0', ' runtime: pi', ].join('\n'), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); if (args.includes('mosaic-agent@coder0.service')) { return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 }; } return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow( 'Fleet stop completed with 1 failure(s)', ); expect(calls).toEqual([ ['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'], ['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'], ['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => { const home = await tempDir(); const program = new Command(); program.exitOverride(); registerFleetCommand(program, { mosaicHome: home, frameworkRoot: resolve(process.cwd(), 'framework'), }); try { await expect( program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']), ).rejects.toThrow('install-systemd only supports the default Mosaic home'); } finally { await rm(home, { recursive: true, force: true }); } }); it.each(['start', 'stop', 'restart', 'status'] as const)( 'rejects single-agent %s for agents outside the roster', async (action) => { const home = await tempDir(); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const runner = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await expect( program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']), ).rejects.toThrow('Agent "typo" is not in the fleet roster'); expect(runner).not.toHaveBeenCalled(); } finally { await rm(home, { recursive: true, force: true }); } }, ); it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => { const home = await tempDir(); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.json'), JSON.stringify({ version: 1, transport: 'tmux', agents: [{ name: 'json-agent', runtime: 'pi' }], }), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']); expect(calls).toEqual([ ['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('passes a deterministic operator source label for agent sends', async () => { const home = await tempDir(); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), JSON.stringify({ version: 1, transport: 'tmux', agents: [{ name: 'json-agent', runtime: 'pi' }], }), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync([ 'node', 'mosaic', 'agent', 'send', 'json-agent', '--message', 'status check', ]); expect(calls).toEqual([ [ join(home, 'tools', 'tmux', 'agent-send.sh'), '-L', 'mosaic-factory', '-S', getDefaultOperatorSourceLabel(), '-s', 'json-agent', '-m', 'status check', ], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('allows agent sends to override the source label explicitly', async () => { const home = await tempDir(); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), JSON.stringify({ version: 1, transport: 'tmux', agents: [{ name: 'coder0', runtime: 'codex' }], }), ); const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync([ 'node', 'mosaic', 'agent', 'send', 'coder0', '--message', 'handoff', '--source-label', 'lead:manual', ]); expect(calls).toEqual([ [ join(home, 'tools', 'tmux', 'agent-send.sh'), '-L', 'mosaic-factory', '-S', 'lead:manual', '-s', 'coder0', '-m', 'handoff', ], ]); } finally { await rm(home, { recursive: true, force: true }); } }); it('rejects agent status typos before invoking the runner', async () => { const home = await tempDir(); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const runner = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await expect( program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']), ).rejects.toThrow('Agent "typo" is not in the fleet roster'); expect(runner).not.toHaveBeenCalled(); } finally { await rm(home, { recursive: true, force: true }); } }); it('keeps fleet framework assets in the published package file list', async () => { const packageJson = JSON.parse( await readFile(resolve(process.cwd(), 'package.json'), 'utf8'), ) as { files?: string[]; }; expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework'])); }); });