Compare commits
3 Commits
feat/fleet
...
feat/fleet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363baf0c81 | ||
| 2849a8f9db | |||
| 7ced5588c9 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.36",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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