From 5bef2c35eb0839f87d816e7fc0fb2cdef73cdc51 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 21 Jun 2026 22:37:34 +0000 Subject: [PATCH] feat(fleet): fleet ps surfaces unmanaged socket sessions (#586) --- packages/mosaic/src/commands/fleet.spec.ts | 269 ++++++++++++++++++++- packages/mosaic/src/commands/fleet.ts | 98 +++++++- 2 files changed, 365 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index e51e32e..c62cea5 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -15,6 +15,7 @@ import { buildSystemdEnableCommand, buildSystemdShowCommand, buildTmuxListPanesCommand, + buildTmuxListSessionsCommand, classifySendResult, detectDrift, enableFleetUnits, @@ -29,6 +30,7 @@ import { parseHeartbeat, parseSystemdShow, parseTmuxListPanes, + parseTmuxListSessions, registerFleetCommand, resolveFleetPaths, RUNTIME_ACCEPTABLE_COMMANDS, @@ -1074,6 +1076,10 @@ describe('fleet ps — JSON output shape (FR-6)', () => { exitCode: 0, }; } + if (fullArgs.includes('list-sessions')) { + // Only the roster agent session on the socket (no unmanaged sessions) + return { stdout: 'canary-pi\n', stderr: '', exitCode: 0 }; + } return { stdout: '', stderr: '', exitCode: 0 }; }; @@ -1117,11 +1123,15 @@ describe('fleet ps — JSON output shape (FR-6)', () => { expect(row.runtime).toBe('pi'); expect(row.systemdActive).toBe('active'); expect(row.systemdEnabled).toBe('disabled'); + + // managed/source fields for roster agents + expect(row.managed).toBe(true); + expect(row.source).toBe('roster'); }); }); describe('fleet ps — command sequences issued', () => { - it('issues systemd show + tmux list-panes per agent', async () => { + it('issues systemd show + tmux list-panes per agent, then list-sessions for socket discovery', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); const rosterPath = join(home, 'fleet', 'roster.yaml'); await mkdir(join(home, 'fleet'), { recursive: true }); @@ -1135,6 +1145,10 @@ describe('fleet ps — command sequences issued', () => { const calls: string[][] = []; const runner: CommandRunner = async (command, args) => { calls.push([command, ...args]); + if ([command, ...args].join(' ').includes('list-sessions')) { + // Only the roster agent — no unmanaged sessions + return { stdout: 'coder0\n', stderr: '', exitCode: 0 }; + } return { stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n', stderr: '', @@ -1155,6 +1169,7 @@ describe('fleet ps — command sequences issued', () => { expect(calls).toEqual([ buildSystemdShowCommand('coder0'), buildTmuxListPanesCommand('coder0', 'mosaic-factory'), + buildTmuxListSessionsCommand('mosaic-factory'), ]); } finally { console.log = origLog; @@ -1163,6 +1178,258 @@ describe('fleet ps — command sequences issued', () => { }); }); +describe('buildTmuxListSessionsCommand', () => { + it('builds exact list-sessions command with session_name format', () => { + expect(buildTmuxListSessionsCommand('mosaic-factory')).toEqual([ + 'tmux', + '-L', + 'mosaic-factory', + 'list-sessions', + '-F', + '#{session_name}', + ]); + }); + + it('uses DEFAULT_SOCKET_NAME when socket is omitted', () => { + const cmd = buildTmuxListSessionsCommand(); + expect(cmd[2]).toBe('mosaic-factory'); + }); +}); + +describe('parseTmuxListSessions', () => { + it('splits newline-delimited session names', () => { + expect(parseTmuxListSessions('canary-pi\n_holder\nsome-adhoc\n')).toEqual([ + 'canary-pi', + '_holder', + 'some-adhoc', + ]); + }); + + it('returns empty array for blank output', () => { + expect(parseTmuxListSessions('')).toEqual([]); + expect(parseTmuxListSessions(' \n \n')).toEqual([]); + }); + + it('trims whitespace from each line', () => { + expect(parseTmuxListSessions(' canary-pi \n some-adhoc \n')).toEqual([ + 'canary-pi', + 'some-adhoc', + ]); + }); +}); + +describe('fleet ps — unmanaged socket sessions', () => { + it('includes unmanaged session row flagged UNMANAGED and excludes _holder', 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 - 10_000) / 1000); + + const runner: CommandRunner = async (command, args) => { + const full = [command, ...args].join(' '); + if (full.includes('list-sessions')) { + // Socket has: canary-pi (roster), _holder (excluded), some-adhoc (unmanaged) + return { stdout: 'canary-pi\n_holder\nsome-adhoc\n', stderr: '', exitCode: 0 }; + } + if (full.includes('list-panes')) { + return { stdout: `99999 bash 0 ${activityEpoch}\n`, stderr: '', exitCode: 0 }; + } + if (full.includes('systemctl') && full.includes('show')) { + return { + stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\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); + + // Should have 2 rows: canary-pi (roster) + some-adhoc (unmanaged); _holder excluded + expect(json).toHaveLength(2); + + // Roster agent comes first + const rosterRow = json[0]!; + expect(rosterRow.name).toBe('canary-pi'); + expect(rosterRow.managed).toBe(true); + expect(rosterRow.source).toBe('roster'); + + // Unmanaged session comes second + const unmanagedRow = json[1]!; + expect(unmanagedRow.name).toBe('some-adhoc'); + expect(unmanagedRow.managed).toBe(false); + expect(unmanagedRow.source).toBe('socket'); + expect(unmanagedRow.runtime).toBe('unknown'); + + // _holder must not appear + expect(json.map((r) => r.name)).not.toContain('_holder'); + + // tenant_id and host must be present on unmanaged rows + expect(typeof unmanagedRow.tenant_id).toBe('string'); + expect(unmanagedRow.tenant_id.length).toBeGreaterThan(0); + expect(typeof unmanagedRow.host).toBe('string'); + expect(unmanagedRow.host.length).toBeGreaterThan(0); + + // driftFlag must be false for unmanaged (no roster runtime to compare) + expect(unmanagedRow.driftFlag).toBe(false); + }); + + it('shows UNMANAGED flag in table output for unmanaged sessions', 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 runner: CommandRunner = async (command, args) => { + const full = [command, ...args].join(' '); + if (full.includes('list-sessions')) { + return { stdout: 'canary-pi\nsome-adhoc\n', stderr: '', exitCode: 0 }; + } + if (full.includes('list-panes')) { + return { stdout: '0 bash 1 0\n', stderr: '', exitCode: 0 }; + } + if (full.includes('systemctl') && full.includes('show')) { + return { + stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\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']); + } finally { + console.log = origLog; + await rm(home, { recursive: true, force: true }); + } + + const tableOutput = lines.join('\n'); + // some-adhoc row must appear with UNMANAGED flag + expect(tableOutput).toMatch(/some-adhoc/); + expect(tableOutput).toMatch(/UNMANAGED/); + // canary-pi roster row must not have UNMANAGED + const rosterLine = lines.find((l) => l.includes('canary-pi')); + expect(rosterLine).toBeDefined(); + expect(rosterLine).not.toMatch(/UNMANAGED/); + }); + + it('gracefully shows only roster rows when list-sessions fails (socket missing)', 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 runner: CommandRunner = async (command, args) => { + const full = [command, ...args].join(' '); + if (full.includes('list-sessions')) { + // Simulate socket missing + return { stdout: '', stderr: 'no server running on /tmp/...', exitCode: 1 }; + } + if (full.includes('list-panes')) { + return { stdout: '12345 pi 0 0\n', stderr: '', exitCode: 0 }; + } + if (full.includes('systemctl') && full.includes('show')) { + return { + stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\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 { + // Must not throw + await expect( + program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']), + ).resolves.toBeDefined(); + } finally { + console.log = origLog; + await rm(home, { recursive: true, force: true }); + } + + const json = JSON.parse(lines.join('')) as AgentPsRow[]; + // Only roster agent visible; no crash + expect(json).toHaveLength(1); + expect(json[0]!.name).toBe('canary-pi'); + expect(json[0]!.managed).toBe(true); + }); +}); + describe('agent watch', () => { it('builds exact grouped-viewer creation command', () => { expect( diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 7845cb6..a5e1b61 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -389,6 +389,10 @@ export interface AgentPsRow { driftFlag: boolean; /** active but UnitFileState=disabled */ bootEnableWarning: boolean; + /** true = came from roster; false = found on socket but not in roster */ + managed: boolean; + /** "roster" = defined in roster.yaml; "socket" = discovered via tmux list-sessions */ + source: 'roster' | 'socket'; } /** @@ -431,6 +435,26 @@ export function buildTmuxListPanesCommand( ]; } +/** + * Returns the tmux list-sessions command to enumerate all sessions on a socket. + * Format: `tmux -L list-sessions -F '#{session_name}'` + * Used to discover ad-hoc sessions that are not in the roster. + */ +export function buildTmuxListSessionsCommand(socketName = DEFAULT_SOCKET_NAME): string[] { + return ['tmux', '-L', socketName, 'list-sessions', '-F', '#{session_name}']; +} + +/** + * Parse the output of `tmux list-sessions -F '#{session_name}'` into an array of session names. + * Returns an empty array on empty/blank output. + */ +export function parseTmuxListSessions(output: string): string[] { + return output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + /** * Returns the heartbeat file path for an agent. */ @@ -897,7 +921,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = cmd .command('ps') - .description('Show real-time status for all roster agents (systemd + tmux + heartbeat)') + .description( + 'Show real-time status for all roster agents and unmanaged socket sessions (systemd + tmux + heartbeat)', + ) .option('--json', 'Print JSON array') .action(async (opts: { json?: boolean }) => { const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); @@ -908,6 +934,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = const rows: AgentPsRow[] = []; + // Build the set of roster agent names for quick lookup when filtering socket sessions. + const rosterAgentNames = new Set(roster.agents.map((a) => a.name)); + for (const agent of roster.agents) { // systemd show const showResult = await runner(...splitCommand(buildSystemdShowCommand(agent.name))); @@ -948,9 +977,75 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = heartbeat: hb, driftFlag, bootEnableWarning, + managed: true, + source: 'roster', }); } + // Enumerate all live sessions on the socket to surface unmanaged (ad-hoc) sessions. + // If list-sessions fails (socket not up), silently skip — show roster rows only. + try { + const listSessionsResult = await runner( + ...splitCommand(buildTmuxListSessionsCommand(roster.tmux.socketName)), + ); + if (listSessionsResult.exitCode === 0) { + const socketSessions = parseTmuxListSessions(listSessionsResult.stdout); + const holderSession = roster.tmux.holderSession; + + for (const sessionName of socketSessions) { + // Skip roster agents (already in rows) and the holder session (infrastructure). + if (rosterAgentNames.has(sessionName) || sessionName === holderSession) { + continue; + } + + // tmux list-panes for pane info + const panesResult = await runner( + ...splitCommand(buildTmuxListPanesCommand(sessionName, roster.tmux.socketName)), + ); + const paneInfo = parseTmuxListPanes(panesResult.stdout, nowMs); + + // heartbeat — try reading the .hb file using the same path convention + const hbFile = heartbeatPath(sessionName, activePaths.mosaicHome); + let hbContent: string | null = null; + try { + hbContent = await readFile(hbFile, 'utf8'); + } catch { + hbContent = null; + } + const hb = parseHeartbeat(hbContent, nowMs); + + // systemd — check if mosaic-agent@.service exists (usually inactive for ad-hoc) + const showResult = await runner(...splitCommand(buildSystemdShowCommand(sessionName))); + const sysInfo = parseSystemdShow(showResult.stdout); + + const bootEnableWarning = + sysInfo.ActiveState === 'active' && sysInfo.UnitFileState === 'disabled'; + + rows.push({ + name: sessionName, + tenant_id, + host, + // runtime unknown — not in roster + runtime: 'unknown', + systemdActive: sysInfo.ActiveState, + systemdEnabled: sysInfo.UnitFileState, + paneAlive: !paneInfo.dead, + panePid: paneInfo.pid, + paneCommand: paneInfo.command, + idleSeconds: paneInfo.idleSeconds, + heartbeat: hb, + // No roster runtime to compare — drift is not meaningful for unmanaged sessions + driftFlag: false, + bootEnableWarning, + managed: false, + source: 'socket', + }); + } + } + } catch { + // list-sessions failed (socket missing or permission error) — show roster rows only + } + if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; @@ -982,6 +1077,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = ? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}` : `unknown`; const flags: string[] = []; + if (!row.managed) flags.push('UNMANAGED'); if (row.driftFlag) flags.push('DRIFT'); if (row.bootEnableWarning) flags.push('BOOT-ENABLE');