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, buildAgentWatchAttachCommand, buildAgentWatchCommand, buildAgentWatchCreateViewerCommand, buildAgentWatchKillViewerCommand, buildAgentVerifyAcceptedCommand, buildFleetServiceCommand, buildSystemdShowCommand, buildTmuxListPanesCommand, classifySendResult, detectDrift, generateAgentEnv, getDefaultOperatorSourceLabel, getDefaultTenantAndHost, getRosterAgent, heartbeatPath, isSendAccepted, loadFleetRoster, mergeAgentEnv, parseHeartbeat, parseSystemdShow, parseTmuxListPanes, registerFleetCommand, resolveFleetPaths, type AgentPsRow, type CommandRunner, type InteractiveRunner, } 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', 'ps', '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', 'watch', ]); }); }); 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'])); }); }); // --------------------------------------------------------------------------- // Phase-2 observability — unit tests (FR-1, FR-3, FR-5, FR-6) // --------------------------------------------------------------------------- describe('fleet ps — command construction', () => { it('builds exact systemd show command for an agent unit', () => { expect(buildSystemdShowCommand('canary-pi')).toEqual([ 'systemctl', '--user', 'show', 'mosaic-agent@canary-pi.service', '-p', 'ActiveState', '-p', 'SubState', '-p', 'UnitFileState', ]); }); it('builds exact tmux list-panes command with the correct format string', () => { expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-factory')).toEqual([ 'tmux', '-L', 'mosaic-factory', 'list-panes', '-t', '=canary-pi:0.0', '-F', '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}', ]); }); it('uses DEFAULT_SOCKET_NAME when socket is omitted from list-panes', () => { const cmd = buildTmuxListPanesCommand('canary-pi'); expect(cmd[2]).toBe('mosaic-factory'); }); it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => { const home = '/home/test/.config/mosaic'; expect(heartbeatPath('canary-pi', home)).toBe( '/home/test/.config/mosaic/fleet/run/canary-pi.hb', ); }); }); describe('fleet ps — heartbeat parsing', () => { const NOW = 1_700_000_000_000; // fixed epoch ms for deterministic tests it('parses a healthy heartbeat file', () => { const ts = new Date(NOW - 10_000).toISOString(); // 10s ago — within 3×15s = 45s const content = `ts=${ts}\npid=12345\nstatus=ok\n`; const hb = parseHeartbeat(content, NOW); expect(hb.health).toBe('healthy'); expect(hb.pid).toBe(12345); expect(hb.status).toBe('ok'); expect(hb.ageMs).toBe(10_000); }); it('reports stale when heartbeat is older than 3×interval', () => { const ts = new Date(NOW - 60_000).toISOString(); // 60s ago > 45s threshold const content = `ts=${ts}\npid=99\nstatus=busy\n`; const hb = parseHeartbeat(content, NOW); expect(hb.health).toBe('stale'); expect(hb.status).toBe('busy'); }); it('reports unknown when heartbeat file is missing (null input)', () => { const hb = parseHeartbeat(null, NOW); expect(hb.health).toBe('unknown'); expect(hb.ts).toBeNull(); expect(hb.pid).toBeNull(); expect(hb.ageMs).toBeNull(); }); it('tolerates missing fields in heartbeat file', () => { const hb = parseHeartbeat('ts=not-a-date\n', NOW); expect(hb.health).toBe('unknown'); expect(hb.ts).toBeNull(); }); }); describe('fleet ps — systemd show parsing', () => { it('parses ActiveState, SubState, UnitFileState from systemctl show output', () => { const output = 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'; expect(parseSystemdShow(output)).toEqual({ ActiveState: 'active', SubState: 'running', UnitFileState: 'enabled', }); }); it('defaults missing keys to "unknown"', () => { const result = parseSystemdShow('ActiveState=inactive\n'); expect(result.SubState).toBe('unknown'); expect(result.UnitFileState).toBe('unknown'); }); }); describe('fleet ps — tmux list-panes parsing', () => { const NOW_MS = 1_700_000_000_000; it('parses alive pane with pid, command, and idle time', () => { const activityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago const output = `12345 claude 0 ${activityEpoch}\n`; const result = parseTmuxListPanes(output, NOW_MS); expect(result.pid).toBe(12345); expect(result.command).toBe('claude'); expect(result.dead).toBe(false); expect(result.idleSeconds).toBe(30); }); it('reports dead pane when pane_dead=1', () => { const output = `0 bash 1 0\n`; const result = parseTmuxListPanes(output, NOW_MS); expect(result.dead).toBe(true); }); it('returns nulls for empty pane output', () => { const result = parseTmuxListPanes('', NOW_MS); expect(result.pid).toBeNull(); expect(result.command).toBeNull(); expect(result.dead).toBe(true); expect(result.idleSeconds).toBeNull(); }); }); describe('fleet ps — drift detection', () => { it('flags drift when roster says pi but pane runs python3', () => { expect(detectDrift('pi', 'python3')).toBe(true); }); it('flags drift when roster says claude but pane runs dogfood-agent.py', () => { expect(detectDrift('claude', 'dogfood-agent.py')).toBe(true); }); it('does NOT flag drift when pane command matches the roster runtime', () => { expect(detectDrift('claude', 'claude')).toBe(false); expect(detectDrift('codex', 'codex')).toBe(false); expect(detectDrift('pi', 'pi')).toBe(false); expect(detectDrift('opencode', 'opencode')).toBe(false); }); it('does NOT flag drift for unknown/custom runtimes (no canonical mapping)', () => { expect(detectDrift('custom-runtime', 'anything')).toBe(false); }); it('does NOT flag drift when pane command is null (pane dead)', () => { expect(detectDrift('pi', null)).toBe(false); }); }); describe('fleet ps — tenant and host', () => { it('returns tenant_id and host as non-empty strings', () => { const { tenant_id, host } = getDefaultTenantAndHost(); expect(typeof tenant_id).toBe('string'); expect(tenant_id.length).toBeGreaterThan(0); expect(typeof host).toBe('string'); expect(host.length).toBeGreaterThan(0); }); }); describe('fleet ps — JSON output shape (FR-6)', () => { it('produces --json records including tenant_id and host for each agent', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( rosterPath, [ 'version: 1', 'transport: tmux', 'agents:', ' - name: canary-pi', ' runtime: pi', ' class: canary', ].join('\n'), ); const nowMs = Date.now(); const activityEpoch = Math.floor((nowMs - 20_000) / 1000); const runner: CommandRunner = async (command, args) => { const fullArgs = [command, ...args].join(' '); if (fullArgs.includes('systemctl') && fullArgs.includes('show')) { return { stdout: 'ActiveState=active\nSubState=running\nUnitFileState=disabled\n', stderr: '', exitCode: 0, }; } if (fullArgs.includes('list-panes')) { return { stdout: `12345 python3 0 ${activityEpoch}\n`, stderr: '', exitCode: 0, }; } return { stdout: '', stderr: '', exitCode: 0 }; }; const lines: string[] = []; const origLog = console.log; console.log = (msg: string) => { lines.push(msg); }; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']); } finally { console.log = origLog; await rm(home, { recursive: true, force: true }); } const json = JSON.parse(lines.join('')) as AgentPsRow[]; expect(Array.isArray(json)).toBe(true); expect(json).toHaveLength(1); const row = json[0]!; // FR-6: tenant_id and host must be present expect(typeof row.tenant_id).toBe('string'); expect(row.tenant_id.length).toBeGreaterThan(0); expect(typeof row.host).toBe('string'); expect(row.host.length).toBeGreaterThan(0); // drift: roster says pi, pane runs python3 → drift flag expect(row.driftFlag).toBe(true); // boot-enable warning: active + disabled expect(row.bootEnableWarning).toBe(true); // heartbeat missing → unknown expect(row.heartbeat.health).toBe('unknown'); expect(row.name).toBe('canary-pi'); expect(row.runtime).toBe('pi'); expect(row.systemdActive).toBe('active'); expect(row.systemdEnabled).toBe('disabled'); }); }); describe('fleet ps — command sequences issued', () => { it('issues systemd show + tmux list-panes per agent', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); 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: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n', stderr: '', exitCode: 0, }; }; // suppress console.log for table output const origLog = console.log; console.log = () => {}; const program = new Command(); program.exitOverride(); registerFleetCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']); expect(calls).toEqual([ buildSystemdShowCommand('coder0'), buildTmuxListPanesCommand('coder0', 'mosaic-factory'), ]); } finally { console.log = origLog; await rm(home, { recursive: true, force: true }); } }); }); describe('agent watch', () => { it('builds exact grouped-viewer creation command', () => { expect( buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-factory'), ).toEqual([ 'tmux', '-L', 'mosaic-factory', 'new-session', '-d', '-t', '=canary-pi', '-s', 'canary-pi-watch-123', ]); }); it('builds exact viewer attach command (read-only)', () => { expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([ 'tmux', '-L', 'mosaic-factory', 'attach', '-r', '-t', 'canary-pi-watch-123', ]); }); it('builds exact viewer kill command', () => { expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([ 'tmux', '-L', 'mosaic-factory', 'kill-session', '-t', 'canary-pi-watch-123', ]); }); it('buildAgentWatchCommand (deprecated) still uses DEFAULT_SOCKET_NAME when socket is omitted', () => { const cmd = buildAgentWatchCommand('canary-pi'); expect(cmd[2]).toBe('mosaic-factory'); expect(cmd).toContain('-r'); }); it('dispatch: creates grouped viewer session (runner) then attaches -r to viewer session (interactiveRunner), NOT a bare attach to the agent session', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const capturingCalls: string[][] = []; const runner: CommandRunner = async (command, args) => { capturingCalls.push([command, ...args]); return { stdout: '', stderr: '', exitCode: 0 }; }; const interactiveCalls: string[][] = []; const interactiveRunner: InteractiveRunner = async (command, args) => { interactiveCalls.push([command, ...args]); return 0; }; const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, interactiveRunner, mosaicHome: home }); try { await program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'coder0']); // The capturing runner must be used for grouped-session creation and cleanup. // It must NOT be used for the interactive attach. expect(capturingCalls).toHaveLength(2); // new-session + kill-session expect(capturingCalls[0]).toEqual( expect.arrayContaining(['new-session', '-d', '-t', '=coder0']), ); // The new-session command must include a viewer session name derived from agent name. expect(capturingCalls[0]!.join(' ')).toMatch(/coder0-watch-\d+/); // Kill-session must target the same viewer session, not the agent session. expect(capturingCalls[1]).toEqual(expect.arrayContaining(['kill-session', '-t'])); expect(capturingCalls[1]!.join(' ')).toMatch(/coder0-watch-\d+/); // The agent session itself must NOT be the attach target. expect(capturingCalls[1]!.join(' ')).not.toContain('=coder0'); // The interactiveRunner must attach -r to the VIEWER session, not the agent session. expect(interactiveCalls).toHaveLength(1); expect(interactiveCalls[0]).toEqual(expect.arrayContaining(['attach', '-r', '-t'])); // Target must be the viewer session name (not "=coder0"). const attachTarget = interactiveCalls[0]![interactiveCalls[0]!.indexOf('-t') + 1]!; expect(attachTarget).toMatch(/coder0-watch-\d+/); expect(attachTarget).not.toBe('=coder0'); } finally { await rm(home, { recursive: true, force: true }); } }); it('rejects watch for agents not in the roster', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['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', 'watch', 'typo']), ).rejects.toThrow('Agent "typo" is not in the fleet roster'); expect(runner).not.toHaveBeenCalled(); } finally { await rm(home, { recursive: true, force: true }); } }); }); describe('agent send --verify', () => { it('builds exact verify capture-pane command', () => { expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-factory', 5)).toEqual([ 'tmux', '-L', 'mosaic-factory', 'capture-pane', '-t', '=canary-pi:0.0', '-p', '-S', '-5', ]); }); it('isSendAccepted: returns "accepted" for normal response output', () => { expect(isSendAccepted('Some response text\nAnother line\n')).toBe('accepted'); }); it('isSendAccepted: returns "draft" when last line starts with "> " (draft pattern)', () => { expect(isSendAccepted('> my unsent message')).toBe('draft'); }); it('isSendAccepted: returns "unverifiable" for blank/empty pane (full-screen TUI case)', () => { expect(isSendAccepted('')).toBe('unverifiable'); expect(isSendAccepted(' \n \n')).toBe('unverifiable'); }); // --------------------------------------------------------------------------- // classifySendResult — BEFORE/AFTER pane-diff classifier (regression suite) // --------------------------------------------------------------------------- describe('classifySendResult (BEFORE/AFTER pane-diff classifier)', () => { it('returns "accepted" when AFTER differs from BEFORE and AFTER has no draft line', () => { const before = 'Old content from prior interaction\n'; const after = 'Old content from prior interaction\nAgent response: task complete.\n'; expect(classifySendResult(before, after)).toBe('accepted'); }); it('returns "draft" when AFTER differs from BEFORE and AFTER ends in a draft line', () => { const before = 'Previous output\n'; const after = 'Previous output\n> unsent message\n'; expect(classifySendResult(before, after)).toBe('draft'); }); it('returns "unverifiable" when AFTER is blank/empty (full-screen TUI blank render)', () => { const before = 'Some previous content\n'; expect(classifySendResult(before, '')).toBe('unverifiable'); expect(classifySendResult(before, ' \n \n')).toBe('unverifiable'); }); it('returns "unverifiable" when AFTER == BEFORE (stale/wedged pane — no change after send)', () => { const staleContent = 'Old non-empty content that never changed\n'; expect(classifySendResult(staleContent, staleContent)).toBe('unverifiable'); }); it('returns "unverifiable" when both BEFORE and AFTER are blank (both blank => no change)', () => { expect(classifySendResult('', '')).toBe('unverifiable'); }); it('returns "accepted" when BEFORE is blank and AFTER has non-draft content (pane woke up)', () => { expect(classifySendResult('', 'Agent is now responding.\n')).toBe('accepted'); }); }); it('issues BEFORE-capture then send then AFTER-capture (3 calls) when --verify is passed', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); let callIndex = 0; const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); const idx = callIndex++; if ([command, ...args].join(' ').includes('agent-send.sh')) { return { stdout: '', stderr: '', exitCode: 0 }; } // BEFORE capture: return old content; AFTER capture: return new content const stdout = idx === 0 ? 'Old pane content\n' : 'New response from agent\n'; 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', 'hello world', '--verify', ]); // 3 calls: BEFORE-capture, send, AFTER-capture expect(calls).toHaveLength(3); expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5)); expect(calls[1]![0]).toContain('agent-send.sh'); expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5)); } finally { await rm(home, { recursive: true, force: true }); } }, 10_000); it('does NOT issue capture-pane verify when --verify is not passed', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['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(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync([ 'node', 'mosaic', 'agent', 'send', 'coder0', '--message', 'hello world', ]); // Only 1 call: agent-send.sh (no capture-pane) expect(calls).toHaveLength(1); expect(calls[0]![0]).toContain('agent-send.sh'); } finally { await rm(home, { recursive: true, force: true }); } }); it('send --verify: AFTER==BEFORE (stale/wedged pane) sets process.exitCode=1 (unverifiable)', async () => { const originalExitCode = process.exitCode; const stderrMessages: string[] = []; const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => { stderrMessages.push(String(msg)); return true; }); const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); const runner: CommandRunner = async (command, args) => { const full = [command, ...args].join(' '); if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 }; // BEFORE and AFTER are identical non-empty stale content — simulates a wedged pane return { stdout: 'Stale old content that never changed\n', stderr: '', exitCode: 0 }; }; const program = new Command(); program.exitOverride(); registerAgentCommand(program, { runner, mosaicHome: home }); try { await program.parseAsync([ 'node', 'mosaic', 'agent', 'send', 'coder0', '--message', 'hello', '--verify', ]); expect(process.exitCode).toBe(1); // Must mention "no pane change" to distinguish from blank-capture case expect(stderrMessages.join('')).toMatch(/no pane change after send/i); } finally { process.exitCode = originalExitCode; stderrSpy.mockRestore(); await rm(home, { recursive: true, force: true }); } }, 10_000); it('send --verify: blank AFTER capture sets process.exitCode=1 (unverifiable, fails closed)', async () => { const originalExitCode = process.exitCode; const stderrMessages: string[] = []; const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => { stderrMessages.push(String(msg)); return true; }); const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); let captureCallCount = 0; const runner: CommandRunner = async (command, args) => { const full = [command, ...args].join(' '); if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 }; captureCallCount++; // BEFORE: some content; AFTER: blank (full-screen TUI renders blank after send) const stdout = captureCallCount === 1 ? 'Previous content\n' : ''; 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', 'hello', '--verify', ]); expect(process.exitCode).toBe(1); expect(stderrMessages.join('')).toMatch(/could not verify delivery/i); } finally { process.exitCode = originalExitCode; stderrSpy.mockRestore(); await rm(home, { recursive: true, force: true }); } }, 10_000); it('send --verify: AFTER differs from BEFORE with draft line sets process.exitCode=1', async () => { const originalExitCode = process.exitCode; const stderrMessages: string[] = []; const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => { stderrMessages.push(String(msg)); return true; }); const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); let captureCallCount = 0; const runner: CommandRunner = async (command, args) => { const full = [command, ...args].join(' '); if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 }; captureCallCount++; // BEFORE: old content; AFTER: message appeared but ended as a draft line const stdout = captureCallCount === 1 ? 'Previous output\n' : '> unsent message\n'; 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', 'hello', '--verify', ]); expect(process.exitCode).toBe(1); expect(stderrMessages.join('')).toMatch(/unsubmitted draft/i); } finally { process.exitCode = originalExitCode; stderrSpy.mockRestore(); await rm(home, { recursive: true, force: true }); } }, 10_000); it('send --verify: AFTER differs from BEFORE with real response content sets exitCode=0 (accepted)', async () => { const originalExitCode = process.exitCode; const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); await mkdir(join(home, 'fleet'), { recursive: true }); await writeFile( join(home, 'fleet', 'roster.yaml'), ['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join( '\n', ), ); let captureCallCount = 0; const runner: CommandRunner = async (command, args) => { const full = [command, ...args].join(' '); if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 }; captureCallCount++; // BEFORE: old content; AFTER: new response content (pane changed) const stdout = captureCallCount === 1 ? 'Old pane content\n' : 'Old pane content\nAgent response: task completed.\n'; 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', 'hello', '--verify', ]); // exitCode should remain unchanged (not set to 1) expect(process.exitCode).toBe(originalExitCode); } finally { process.exitCode = originalExitCode; await rm(home, { recursive: true, force: true }); } }, 10_000); });