feat(daemon,cli): add rigName alias, lifecycleState column, and bounded ps output options

Builds on the Lifecycle v2 epic (L1+L2+L3+L3b) by making `rig ps` operator-
trustworthy and scale-safe. The slice covers three linked outcomes against
the same operator surface:

1. **Naming consistency.** Per-node `NodeInventoryEntry` exposes the rig's
   name as `rigName`; rig-summary `PsEntry` historically used `name`. Agent
   code that projected `.rigName` from `rig ps --json` saw silent
   `null`/`undefined`. Fixed by adding `rigName` as an alias on `PsEntry`,
   always populated and always equal to `name`. Existing consumers that
   read `.name` continue to work; agent code should now prefer `rigName`.

2. **Lifecycle visibility.** L2's `lifecycleState` column was already in
   the default human `rig ps` output; this slice asserts it via test and
   documents it in the help text.

3. **Output budget.** Hosts with realistic agent counts (~75 nodes already
   on the dev box; targeting 100+) flooded operator terminals and agent
   context windows. The fix is hybrid: human terminal output truncates by
   default to 50 rigs / 100 nodes with an honest footer naming actual
   totals and the `--full` opt-out; JSON output preserves the bare-array
   default for machine consumers but adds opt-in `--limit`/`--fields`/
   `--summary`/`--filter` flags that switch to an envelope shape with
   `entries`/`totalRigs`/`truncated`/`hint`. The envelope only triggers
   when at least one shaping flag is specified.

Decisions resolved (per plan, ratified by orch-lead with amendments):

- A2 — alias `rigName` alongside `name` (zero JSON consumer breakage).
- B1 — keep LIFECYCLE column in default human output (already present from L2).
- C hybrid — human truncates default with footer; JSON unbounded by default;
  envelope only on `--limit`/`--fields`/`--summary`/`--filter` flag use.

Per orch amendments (overriding plan where they conflict):

- **A. CLI-side shaping.** All filtering/limiting/summary/projection
  happens in the CLI; no daemon query parameters added; `/api/ps` stays
  bare-array with the new `rigName` alias only. `routes/ps.ts` and
  `routes/rigs.ts` are NOT touched.
- **B. Guard pre-commit review.** Driver sent kickoff with `Verified: yes`,
  implemented + verified, filed a pre-commit checkpoint, created a durable
  guard review qitem, and waited for guard accept BEFORE committing. This
  commit lands only after guard issued ACCEPT in
  `velocity-slice-rig-ps-trustworthy-guard-checkpoint-approve-20260428.md`.
- **C. Smallest fixture proving budget boundary.** A 60-rig synthetic
  fixture (just over the 50-budget) proves human-truncation correctness
  in 35ms; the L4 proof at `fad10a9` already exercised real 100x10 scale.
  Justification recorded in the test comment.

Implementation:

- `packages/daemon/src/domain/ps-projection.ts`: add `rigName: string`
  field to `PsEntry` (always populated, always equal to `name`).
  `lifecycleState` typing remains required (post-L2); empty rigs derive
  `stopped`. `routes/ps.ts` is unchanged because `PsEntry` IS the wire
  shape and `rigName` flows through automatically.

- `packages/cli/src/commands/ps.ts`: add `--full`, `--limit`, `--fields`,
  `--summary`, `--filter` flags. Implement allow-list filter parser
  (`status`, `lifecycleState`, `name-prefix`, `name`) with explicit
  exit-1 errors for unknown keys / malformed values / non-integer
  `--limit`. Implement human truncation at `HUMAN_RIG_BUDGET=50` /
  `HUMAN_NODE_BUDGET=100` with honest footer. Implement JSON envelope
  triggered ONLY by `useEnvelope` (any of `--limit`/`--summary`/
  `--fields`/`--filter`); default `--json` and `--full --json` keep
  the bare-array shape for back-compat. Help text rewritten with all
  new flag examples and a note about the `rigName` alias.

Filter parser security: in-memory filtering over already-fetched arrays;
allow-list Set rejects unknown keys before any HTTP call; no SQL
injection vector.

Tests added (17 cases total: 2 daemon + 15 CLI):

- `daemon/test/ps-projection.test.ts`: `rigName` alias populated equal
  to `name` on every entry; `lifecycleState` always populated even for
  empty rigs.
- `cli/test/ps.test.ts`: `--json` rigName pass-through (jq `.[].rigName`
  regression named in the source qitem); `--json` default bare array;
  `--json --limit` envelope; `--json --fields` projection;
  `--json --summary` aggregate-only; `--filter status=running`;
  `--filter lifecycleState=attention_required`; filter rejects unknown
  keys; filter rejects malformed value; `--limit` rejects non-integer;
  60-rig fixture proves 50-budget human truncation with honest footer;
  `--full` disables truncation; `--json` default bare-array preserved
  with 60 rigs (back-compat); `--nodes --json` default unbounded
  (back-compat); `--nodes --json --limit` envelope.

Verification:

- focused daemon: `npx vitest run test/ps-projection.test.ts test/node-inventory.test.ts`
  → 43/43 green.
- focused CLI: `npx vitest run test/ps.test.ts` → 33/33 green.
- full daemon suite: 2087/2087 (+2 from L3b baseline of 2085).
- full CLI suite: 4 pre-existing failures (port 7433 in use; chatroom
  timeout; config reads env). Same set documented in L2/L3/L3b
  checkpoints; verified not L3-followup regressions.
- `npx tsc --noEmit` clean on both packages.

Invariants preserved:

- **Backward compat for `rig ps --json`**: default (no flags) returns
  bare array with `rigName` alias added; `name` field preserved; existing
  consumers reading `.name` keep working.
- **Backward compat for `rig ps --nodes --json`**: same.
- **Envelope only on flag use**: `--full --json` keeps bare array;
  envelope only when shaping flag specified.
- **No silent nulls**: `lifecycleState` typed as required + asserted in
  test.
- **Truncation honesty**: footer always names actual totals + next-command
  hint; never silently drops.
- **Filter parser security**: allow-list only; no injection vector.
- **L1/L2/L3/L3b invariants untouched**: no changes to reconcile,
  lifecycle derivation, restore-attempt model, snapshot selection, or
  any L1-L3b surface.
- **No new schema migration; no daemon query parameters; no
  RestoreOrchestrator/rig-repository/node-inventory changes; no
  UI/MCP/Auth-C/OAuth/account/quota/`~/.codex` changes; no host
  lifecycle mutation; no kernel-rig dependency; no recurring
  drain/watchdog; no retired queue-fallback revival.**

Refs:
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-and-scale-safe-plan-20260428.md
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-driver-checkpoint-20260428.md (pre-commit driver checkpoint)
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-guard-checkpoint-approve-20260428.md (guard ACCEPT)
- L3b predecessor: e70694f fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists
- L3 predecessor: 66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
- L2 predecessor: fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
- L1 predecessor: 3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-04-28 16:58:52 -07:00
1 parent e70694f660
commit 0a9fb436cd
4 files changed
+545 -24

No files matched your search

+276 -24
View File
@@ -7,6 +7,8 @@ import type { StatusDeps } from "./status.js";
interface PsEntry {
rigId: string;
name: string;
/** L3-followup: alias of `name`. Always populated and equal to `name`. */
rigName?: string;
nodeCount: number;
runningCount: number;
status: "running" | "partial" | "stopped";
@@ -41,6 +43,111 @@ interface NodeEntry {
[key: string]: unknown;
}
// L3-followup: human-output budgets bound default terminal output for hosts
// with realistic agent counts. `--full` opts out. JSON output remains
// unbounded by default for back-compat (Decision C hybrid).
const HUMAN_RIG_BUDGET = 50;
const HUMAN_NODE_BUDGET = 100;
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
// clear error with the supported list (Guard Review Checklist: filter-parser
// security).
const ALLOWED_FILTER_KEYS = new Set(["status", "lifecycleState", "name-prefix", "name"]);
interface PsCliOptions {
json?: boolean;
nodes?: boolean;
full?: boolean;
limit?: string;
fields?: string;
summary?: boolean;
filter?: string;
}
interface ParsedFilter {
key: string;
value: string;
}
function parseFilter(filter: string): ParsedFilter | { error: string } {
const idx = filter.indexOf("=");
if (idx === -1) return { error: `--filter must be key=value; got: '${filter}'` };
const key = filter.slice(0, idx);
const value = filter.slice(idx + 1);
if (!ALLOWED_FILTER_KEYS.has(key)) {
return {
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
};
}
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
return { key, value };
}
function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
return entries.filter((e) => {
if (filter.key === "status") return e.status === filter.value;
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
return true;
});
}
function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[] {
return entries.filter((n) => {
// status maps to sessionStatus for nodes; lifecycleState applies directly.
if (filter.key === "status") return n.sessionStatus === filter.value;
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
if (filter.key === "name") return n.rigName === filter.value;
return true;
});
}
function selectFields<T extends Record<string, unknown>>(entries: T[], fields: string[]): Array<Record<string, unknown>> {
return entries.map((e) => {
const out: Record<string, unknown> = {};
for (const f of fields) out[f] = e[f];
return out;
});
}
function summarizeRigs(entries: PsEntry[]): {
totalRigs: number;
totalRunning: number;
byLifecycle: Record<string, number>;
byStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const byStatus: Record<string, number> = {};
let totalRunning = 0;
for (const e of entries) {
const ls = e.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
if (e.status === "running") totalRunning++;
}
return { totalRigs: entries.length, totalRunning, byLifecycle, byStatus };
}
function summarizeNodes(entries: NodeEntry[]): {
totalNodes: number;
byLifecycle: Record<string, number>;
bySessionStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const bySessionStatus: Record<string, number> = {};
for (const n of entries) {
const ls = n.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
const ss = n.sessionStatus ?? "unknown";
bySessionStatus[ss] = (bySessionStatus[ss] ?? 0) + 1;
}
return { totalNodes: entries.length, byLifecycle, bySessionStatus };
}
// Compact rig-level lifecycle codes for the rig table header (3-char fixed width).
function abbrevRigLifecycle(state: PsEntry["lifecycleState"] | undefined): string {
if (!state) return "—";
@@ -64,29 +171,53 @@ function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): st
/**
* `rig ps` — list running rigs and optionally their nodes.
* @param depsOverride - injectable deps for testing
* @returns Commander command
*
* L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`,
* `--filter`, `--full`) for context-window-safe output on large hosts. Default
* `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals
* and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape
* for back-compat; the truncation/envelope shape only applies when at least
* one of `--limit`/`--summary`/`--fields`/`--filter` is specified.
*/
export function psCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("ps")
.description("List rigs and their status")
.addHelpText("after", `
Examples:
rig ps Show all rigs with status
rig ps --json JSON output for piping/parsing
rig ps --nodes Show per-node detail for all rigs
rig ps --nodes --json Node inventory as JSON array
rig ps Show all rigs (human; truncated to ${HUMAN_RIG_BUDGET} with footer)
rig ps --full Disable human truncation
rig ps --json JSON output (bare array; back-compat)
rig ps --json --limit 20 Bounded JSON envelope
rig ps --json --summary Aggregate-only JSON (no per-rig entries)
rig ps --json --fields rigName,status,lifecycleState
Project JSON to named fields
rig ps --filter lifecycleState=attention_required
Show only rigs needing attention
rig ps --filter status=running Show only running rigs
rig ps --filter name-prefix=demo Filter by rig-name prefix
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
--filter accepts: status, lifecycleState, name-prefix, name. Other keys are rejected.
Exit codes:
0 Success
1 Daemon not running
1 Daemon not running, or invalid --filter / --limit
2 Failed to fetch data from daemon`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--nodes", "Show per-node detail for all rigs")
.action(async (opts: { json?: boolean; nodes?: boolean }) => {
.option("--full", "Disable default human-output truncation")
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name")
.action(async (opts: PsCliOptions) => {
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -96,10 +227,32 @@ Exit codes:
return;
}
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
let parsedFilter: ParsedFilter | null = null;
if (opts.filter) {
const result = parseFilter(opts.filter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
return;
}
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
process.exitCode = 1;
return;
}
const fields = opts.fields ? opts.fields.split(",").map((f) => f.trim()).filter((f) => f.length > 0) : null;
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
const client = deps.clientFactory(getDaemonUrl(status));
if (opts.nodes) {
await handleNodes(client, opts.json ?? false);
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope);
return;
}
@@ -111,24 +264,66 @@ Exit codes:
return;
}
const entries = res.data;
const all = res.data;
// Apply CLI-side filter (Amendment A: prefer CLI shaping).
const filtered = parsedFilter ? applyRigFilter(all, parsedFilter) : all;
// Summary mode short-circuits per-entry output.
if (opts.summary) {
const summary = summarizeRigs(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalRigs: ${summary.totalRigs}`);
console.log(`totalRunning: ${summary.totalRunning}`);
console.log(`byStatus: ${JSON.stringify(summary.byStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
// Apply --limit on top of filter (CLI side; daemon stays bare-array).
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const truncated = limit !== null && filtered.length > limit;
// Field projection runs last so requested fields apply to the limited set.
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
console.log(JSON.stringify(entries));
if (useEnvelope) {
// Envelope only on flag use; default JSON stays a bare array for compat.
const envelope: Record<string, unknown> = {
entries: projected,
totalRigs: filtered.length,
truncated,
};
if (truncated) envelope.hint = "rig ps --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
if (entries.length === 0) {
console.log("No rigs");
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No rigs");
}
return;
}
// Formatted table
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
console.log(header);
for (const e of entries) {
for (const e of humanList as PsEntry[]) {
console.log(padRigRow(
e.name,
e.rigName ?? e.name,
String(e.nodeCount),
String(e.runningCount),
e.status,
@@ -137,13 +332,27 @@ Exit codes:
e.latestSnapshot ?? "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_RIG_BUDGET;
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
console.log("Run 'rig ps --full' to see all, or 'rig ps --filter lifecycleState=attention_required' to narrow.");
} else if (truncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
});
return cmd;
}
async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
// Fetch rig list first
async function handleNodes(
client: DaemonClient,
opts: PsCliOptions,
parsedFilter: ParsedFilter | null,
limit: number | null,
fields: string[] | null,
useEnvelope: boolean,
): Promise<void> {
const rigRes = await client.get<PsEntry[]>("/api/ps");
if (rigRes.status >= 400) {
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
@@ -155,25 +364,60 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
for (const rig of rigRes.data) {
const nodesRes = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
if (nodesRes.status >= 400) {
console.error(`Warning: failed to fetch nodes for rig "${rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
continue;
}
allNodes.push(...nodesRes.data);
}
if (json) {
console.log(JSON.stringify(allNodes));
const filtered = parsedFilter ? applyNodeFilter(allNodes, parsedFilter) : allNodes;
if (opts.summary) {
const summary = summarizeNodes(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalNodes: ${summary.totalNodes}`);
console.log(`bySessionStatus: ${JSON.stringify(summary.bySessionStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
if (allNodes.length === 0) {
console.log("No nodes");
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const limitTruncated = limit !== null && filtered.length > limit;
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
if (useEnvelope) {
const envelope: Record<string, unknown> = {
entries: projected,
totalNodes: filtered.length,
truncated: limitTruncated,
};
if (limitTruncated) envelope.hint = "rig ps --nodes --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No nodes match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No nodes");
}
return;
}
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "RESTORE", "ERROR");
console.log(header);
for (const n of allNodes) {
for (const n of humanList as NodeEntry[]) {
const parts = n.logicalId.split(".");
const pod = n.podNamespace ?? (parts.length > 1 ? parts[0]! : "—");
const member = parts.length > 1 ? parts.slice(1).join(".") : n.logicalId;
@@ -192,6 +436,14 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
n.latestError ? truncate(n.latestError, 30) : "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_NODE_BUDGET;
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
console.log("Run 'rig ps --nodes --full' to see all, or '--filter lifecycleState=attention_required' to narrow.");
} else if (limitTruncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
}
function formatActivity(activity: NodeEntry["agentActivity"]): string {
+231
View File
@@ -497,4 +497,235 @@ describe("Ps CLI", () => {
const parsed = JSON.parse(logs.join(""));
expect(parsed[0].lifecycleState).toBe("recoverable");
});
// L3-followup: trustworthy and scale-safe rig ps.
describe("L3-followup: trustworthy and scale-safe rig ps", () => {
it("ps --json passes through rigName alias from server (jq '.[].rigName' regression)", async () => {
psData = [
{ rigId: "rig-1", name: "alpha", rigName: "alpha", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].name).toBe("alpha");
expect(parsed[0].rigName).toBe("alpha");
});
it("ps --json without flags emits a bare array (back-compat)", async () => {
psData = [
{ rigId: "rig-1", name: "alpha", rigName: "alpha", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
{ rigId: "rig-2", name: "beta", rigName: "beta", nodeCount: 1, runningCount: 0, status: "stopped", lifecycleState: "stopped", uptime: null, latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(2);
});
it("ps --json --limit emits envelope shape with truncated/totalRigs/hint", async () => {
psData = Array.from({ length: 10 }, (_, i) => ({
rigId: `rig-${i}`, name: `r${i}`, rigName: `r${i}`,
nodeCount: 1, runningCount: 1, status: "running" as const, lifecycleState: "running" as const,
uptime: "1m", latestSnapshot: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--limit", "3"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(false);
expect(parsed.entries).toHaveLength(3);
expect(parsed.totalRigs).toBe(10);
expect(parsed.truncated).toBe(true);
expect(parsed.hint).toContain("--full");
});
it("ps --json --fields projects only named fields", async () => {
psData = [
{ rigId: "rig-1", name: "alpha", rigName: "alpha", nodeCount: 2, runningCount: 1, status: "partial", lifecycleState: "degraded", uptime: "1m", latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--fields", "rigName,status,lifecycleState"]);
});
const parsed = JSON.parse(logs.join(""));
// Envelope (because --fields specified)
expect(parsed.entries[0]).toEqual({ rigName: "alpha", status: "partial", lifecycleState: "degraded" });
expect(parsed.entries[0].nodeCount).toBeUndefined();
});
it("ps --json --summary emits aggregate-only shape", async () => {
psData = [
{ rigId: "rig-1", name: "a", rigName: "a", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
{ rigId: "rig-2", name: "b", rigName: "b", nodeCount: 1, runningCount: 0, status: "stopped", lifecycleState: "stopped", uptime: null, latestSnapshot: null },
{ rigId: "rig-3", name: "c", rigName: "c", nodeCount: 1, runningCount: 0, status: "stopped", lifecycleState: "attention_required", uptime: null, latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--summary"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.totalRigs).toBe(3);
expect(parsed.totalRunning).toBe(1);
expect(parsed.byLifecycle.running).toBe(1);
expect(parsed.byLifecycle.stopped).toBe(1);
expect(parsed.byLifecycle.attention_required).toBe(1);
expect(parsed.entries).toBeUndefined();
});
it("ps --filter status=running narrows entries", async () => {
psData = [
{ rigId: "rig-1", name: "a", rigName: "a", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
{ rigId: "rig-2", name: "b", rigName: "b", nodeCount: 1, runningCount: 0, status: "stopped", lifecycleState: "stopped", uptime: null, latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "status=running"]);
});
const parsed = JSON.parse(logs.join(""));
// Envelope because --filter specified
expect(parsed.entries).toHaveLength(1);
expect(parsed.entries[0].name).toBe("a");
});
it("ps --filter lifecycleState=attention_required narrows entries", async () => {
psData = [
{ rigId: "rig-1", name: "a", rigName: "a", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
{ rigId: "rig-2", name: "b", rigName: "b", nodeCount: 1, runningCount: 1, status: "running", lifecycleState: "attention_required", uptime: "1m", latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "lifecycleState=attention_required"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.entries).toHaveLength(1);
expect(parsed.entries[0].lifecycleState).toBe("attention_required");
});
it("ps --filter rejects unknown keys with actionable error and exits 1", async () => {
psData = [];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "unknown=foo"]);
});
expect(logs.some((l) => l.includes("Unknown --filter key 'unknown'"))).toBe(true);
expect(logs.some((l) => l.includes("Supported:"))).toBe(true);
expect(exitCode).toBe(1);
});
it("ps --filter rejects malformed value (no '=') and exits 1", async () => {
psData = [];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "noequals"]);
});
expect(logs.some((l) => l.includes("--filter must be key=value"))).toBe(true);
expect(exitCode).toBe(1);
});
it("ps --limit rejects non-numeric values and exits 1", async () => {
psData = [];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--limit", "abc"]);
});
expect(logs.some((l) => l.includes("--limit must be a non-negative integer"))).toBe(true);
expect(exitCode).toBe(1);
});
// Synthetic large-host fixture: 60 rigs (just over default 50 budget) is
// the smallest fixture that proves human-truncation behavior. Per Amendment
// C: the L4 proof already exercised real 100x10 scale; this test asserts
// the truncation path fires and the footer is honest. Using 60 instead of
// 100 keeps the test fast while still proving the budget boundary.
it("ps human output truncates above HUMAN_RIG_BUDGET (50) with honest footer (Amendment C)", async () => {
psData = Array.from({ length: 60 }, (_, i) => ({
rigId: `rig-${String(i).padStart(2, "0")}`,
name: `r${String(i).padStart(2, "0")}`,
rigName: `r${String(i).padStart(2, "0")}`,
nodeCount: 1, runningCount: 1, status: "running" as const, lifecycleState: "running" as const,
uptime: "1m", latestSnapshot: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps"]);
});
const output = logs.join("\n");
// Header present
expect(output).toContain("LIFECYCLE");
// First 50 rig names visible
expect(output).toContain("r00");
expect(output).toContain("r49");
// Beyond-budget rigs not in default human output
expect(output).not.toContain("r59");
// Truncation footer with actual remaining count
expect(output).toMatch(/and 10 more rigs \(truncated at 50\)/);
expect(output).toContain("rig ps --full");
});
it("ps --full disables human truncation; all 60 rigs printed", async () => {
psData = Array.from({ length: 60 }, (_, i) => ({
rigId: `rig-${i}`, name: `r${i}`, rigName: `r${i}`,
nodeCount: 1, runningCount: 1, status: "running" as const, lifecycleState: "running" as const,
uptime: "1m", latestSnapshot: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--full"]);
});
const output = logs.join("\n");
expect(output).toContain("r0");
expect(output).toContain("r59");
expect(output).not.toMatch(/truncated/);
});
it("ps --json default unbounded for large rigs: emits all 60 entries (back-compat)", async () => {
psData = Array.from({ length: 60 }, (_, i) => ({
rigId: `rig-${i}`, name: `r${i}`, rigName: `r${i}`,
nodeCount: 1, runningCount: 1, status: "running" as const, lifecycleState: "running" as const,
uptime: "1m", latestSnapshot: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
// Default JSON stays a bare array of all 60; NO envelope.
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(60);
});
it("ps --nodes --json default unbounded: bare array (back-compat)", async () => {
psData = [
{ rigId: "rig-1", name: "test", rigName: "test", nodeCount: 2, runningCount: 1, status: "partial", lifecycleState: "degraded", uptime: "1m", latestSnapshot: null },
];
nodesData["rig-1"] = Array.from({ length: 5 }, (_, i) => ({
rigId: "rig-1", rigName: "test", logicalId: `dev.n${i}`, podId: "pod-1", podNamespace: "dev",
canonicalSessionName: `n${i}@test`, nodeKind: "agent", runtime: "claude-code",
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "n-a", lifecycleState: "running",
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(5);
});
it("ps --nodes --json --limit emits envelope shape with totalNodes/truncated/hint", async () => {
psData = [
{ rigId: "rig-1", name: "test", rigName: "test", nodeCount: 5, runningCount: 5, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
];
nodesData["rig-1"] = Array.from({ length: 5 }, (_, i) => ({
rigId: "rig-1", rigName: "test", logicalId: `dev.n${i}`, podId: "pod-1", podNamespace: "dev",
canonicalSessionName: `n${i}@test`, nodeKind: "agent", runtime: "claude-code",
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "n-a", lifecycleState: "running",
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--limit", "2"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.entries).toHaveLength(2);
expect(parsed.totalNodes).toBe(5);
expect(parsed.truncated).toBe(true);
expect(parsed.hint).toContain("--nodes");
expect(parsed.hint).toContain("--full");
});
});
});
@@ -5,9 +5,21 @@ import { getNodeInventory } from "./node-inventory.js";
export interface PsEntry {
rigId: string;
name: string;
/**
* Alias of `name`. Always populated and always equal to `name`.
*
* Background: per-node `NodeInventoryEntry` exposes the rig's name as
* `rigName` while rig-summary `PsEntry` historically used `name`. Agent
* code that projects `.rigName` from rig-summary JSON saw silent
* `null`/`undefined`. This alias closes that inconsistency without
* breaking existing consumers reading `.name`.
*/
rigName: string;
nodeCount: number;
runningCount: number;
status: "running" | "partial" | "stopped";
/** Always populated. Folded from per-node `lifecycleState` (post-L2);
* empty rigs derive `stopped`. Never undefined or null. */
lifecycleState: RigLifecycleState;
uptime: string | null;
latestSnapshot: string | null;
@@ -98,6 +110,7 @@ export class PsProjectionService {
return {
rigId: r.rig_id,
name: r.name,
rigName: r.name,
nodeCount: r.node_count,
runningCount: r.running_count,
status,
@@ -257,6 +257,31 @@ describe("PsProjectionService", () => {
expect(entries[0]!.lifecycleState).toBe("attention_required");
});
// L3-followup: rigName alias is always populated and equal to name.
it("rigName alias is populated and equal to name on every entry (L3-followup)", () => {
const rigA = seedRig("alpha");
const rigB = seedRig("beta");
seedNode(rigA, "dev");
seedNode(rigB, "qa");
const entries = ps.getEntries();
expect(entries.length).toBeGreaterThan(0);
for (const e of entries) {
expect(typeof e.rigName).toBe("string");
expect(e.rigName.length).toBeGreaterThan(0);
expect(e.rigName).toBe(e.name);
}
});
it("lifecycleState is always populated even for empty rigs (L3-followup)", () => {
seedRig("empty-rig");
const entries = ps.getEntries();
expect(entries[0]!.lifecycleState).toBeDefined();
expect(entries[0]!.lifecycleState).not.toBeNull();
expect(entries[0]!.lifecycleState).toBe("stopped");
});
// Pure unit-level coverage of the fold helper.
it("deriveRigLifecycleState fold helper covers all branches", () => {
expect(deriveRigLifecycleState([])).toBe("stopped");