mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
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:e70694ffix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists - L3 predecessor:66785c8feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation - L2 predecessor:fad10a9feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection - L1 predecessor:3ad1df6fix(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:
1 parent
e70694f660
commit
0a9fb436cd
4 files changed
+545
-24
No files matched your search
+276
-24
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user