feat(fleet): fleet ps surfaces unmanaged socket sessions (#586)
This commit was merged in pull request #586.
This commit is contained in:
@@ -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 <socket> 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@<name>.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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user