mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(context): strict threshold validation + refresh hard-fail + CLI tests
Cycle-2 of bad82b3 per QA REJECT-AND-REVISE.
QA Fact 1 (missing tests): added 10 CLI context tests covering
human output, JSON shape, --rig scoping, --threshold filtering
with stale/unknown retention, sort order, stale-vs-ok, unknown
honesty, and invalid threshold rejection.
QA Fact 2 (threshold parsing bug): replaced bare parseInt with
strict Number() + isInteger + 0-100 range check. Invalid input
(abc, -1, 101) now exits 2 with copy matching rigx-context
oracle: "must be an integer percentage".
QA Fact 3 (refresh failure semantics): refresh failure is now a
hard error — exits 2 with fact/detail/fix copy. No silent stale
data display. Non-refresh reads continue to work normally.
Tests: context.test.ts 10/10; CLI 456/457 (preexisting chatroom);
daemon 1812/1812; UI 396/396; tsc clean all workspaces.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
bad82b3a7b
commit
a5d5fc3eae
2 files changed
+227
-23
No files matched your search
@@ -158,10 +158,23 @@ Examples:
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig <name>", "Show one rig only")
|
||||
.option("--threshold <pct>", "Show seats at or above this percentage", parseInt)
|
||||
.option("--threshold <pct>", "Show seats at or above this percentage")
|
||||
.option("--refresh", "Re-sample context usage before displaying")
|
||||
.action(async (opts: { json?: boolean; rig?: string; threshold?: number; refresh?: boolean }) => {
|
||||
.action(async (opts: { json?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// Strict threshold validation — reject non-integer input
|
||||
let threshold: number | null = null;
|
||||
if (opts.threshold != null) {
|
||||
const parsed = Number(opts.threshold);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
|
||||
console.error("rig context: --threshold must be an integer percentage (0-100)");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
threshold = parsed;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
@@ -184,26 +197,25 @@ Examples:
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh once globally if requested (pollOnce samples all active Claude sessions)
|
||||
let refreshFailed = false;
|
||||
// Refresh once globally if requested (pollOnce samples all active Claude sessions).
|
||||
// Refresh failure is a hard error — do not silently display stale data as if refreshed.
|
||||
if (opts.refresh && targetRigs.length > 0) {
|
||||
const firstRig = targetRigs[0]!;
|
||||
try {
|
||||
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
|
||||
if (refreshResult.status >= 400) {
|
||||
refreshFailed = true;
|
||||
if (!opts.json) {
|
||||
console.error("Context refresh failed. Displaying stale data.");
|
||||
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
|
||||
console.error("");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
refreshFailed = true;
|
||||
if (!opts.json) {
|
||||
console.error("Context refresh failed. Displaying stale data.");
|
||||
console.error("");
|
||||
console.error("Context refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
console.error("Context refresh failed. Data may be stale.");
|
||||
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
||||
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +231,7 @@ Examples:
|
||||
const seats = sortSeats(
|
||||
allNodes
|
||||
.map(analyze)
|
||||
.filter((s) => isVisible(s, opts.threshold ?? null))
|
||||
.filter((s) => isVisible(s, threshold))
|
||||
);
|
||||
|
||||
const summary = {
|
||||
@@ -233,12 +245,7 @@ Examples:
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
const output: Record<string, unknown> = { seats, summary };
|
||||
if (refreshFailed) {
|
||||
output.refreshFailed = true;
|
||||
output.refreshError = "Context refresh failed. Data may be stale.";
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
console.log(JSON.stringify({ seats, summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { contextCommand, type ContextDeps } from "../src/commands/context.js";
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
daemonState?: string;
|
||||
rigNames?: string[];
|
||||
nodesByRig?: Record<string, unknown[]>;
|
||||
}): ContextDeps {
|
||||
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({
|
||||
rigId: `rig-${i}`, name, nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null,
|
||||
}));
|
||||
|
||||
const nodesByRig = overrides?.nodesByRig ?? {
|
||||
"rig-0": [
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.impl", canonicalSessionName: "dev-impl@test-rig",
|
||||
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 85, remainingPercentage: 15, contextWindowSize: 1000000,
|
||||
source: "claude_statusline_json", availability: "known",
|
||||
sampledAt: new Date(Date.now() - 60_000).toISOString(), fresh: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.qa", canonicalSessionName: "dev-qa@test-rig",
|
||||
nodeKind: "agent", runtime: "codex", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
|
||||
},
|
||||
{
|
||||
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.synth", canonicalSessionName: "dev-synth@test-rig",
|
||||
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
|
||||
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
|
||||
contextUsage: {
|
||||
usedPercentage: 50, remainingPercentage: 50, contextWindowSize: 1000000,
|
||||
source: "claude_statusline_json", availability: "known",
|
||||
sampledAt: new Date(Date.now() - 3600_000).toISOString(), fresh: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path === "/api/ps") return { status: 200, data: rigs };
|
||||
for (const [key, nodes] of Object.entries(nodesByRig)) {
|
||||
if (path.includes(key)) return { status: 200, data: nodes };
|
||||
}
|
||||
return { status: 200, data: [] };
|
||||
}),
|
||||
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
// Patch getDaemonStatus + getDaemonUrl for tests
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("rig context", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("human output includes seat table with status + fleet summary", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig"]);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("CONTEXT USAGE");
|
||||
expect(output).toContain("dev-impl@test-rig");
|
||||
expect(output).toContain("CRITICAL");
|
||||
expect(output).toContain("dev-qa@test-rig");
|
||||
expect(output).toContain("unknown");
|
||||
expect(output).toContain("dev-synth@test-rig");
|
||||
expect(output).toContain("stale");
|
||||
expect(output).toContain("FLEET SUMMARY:");
|
||||
});
|
||||
|
||||
it("JSON output includes seats array + summary with correct fields", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
expect(json.seats).toBeInstanceOf(Array);
|
||||
expect(json.seats.length).toBe(3);
|
||||
expect(json.summary).toBeDefined();
|
||||
expect(json.summary.total).toBe(3);
|
||||
|
||||
const critical = json.seats.find((s: { session: string }) => s.session === "dev-impl@test-rig");
|
||||
expect(critical.urgency).toBe("critical");
|
||||
expect(critical.freshness).toBe("fresh");
|
||||
expect(critical.status).toBe("critical");
|
||||
expect(critical.usedPercentage).toBe(85);
|
||||
|
||||
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
|
||||
expect(unknown.urgency).toBe("unknown");
|
||||
expect(unknown.status).toBe("unknown");
|
||||
|
||||
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
|
||||
expect(stale.urgency).toBe("low");
|
||||
expect(stale.freshness).toBe("stale");
|
||||
expect(stale.status).toBe("stale");
|
||||
});
|
||||
|
||||
it("sort order: critical > warning > stale > ok > unknown", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const sessions = json.seats.map((s: { session: string }) => s.session);
|
||||
// critical first, then stale (low+stale), then unknown
|
||||
expect(sessions).toEqual(["dev-impl@test-rig", "dev-synth@test-rig", "dev-qa@test-rig"]);
|
||||
});
|
||||
|
||||
it("--rig filters to one rig", async () => {
|
||||
const cmd = contextCommand(makeDeps({ rigNames: ["rig-a", "rig-b"] }));
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "rig-a", "--json"]);
|
||||
|
||||
const output = logs.join("\n");
|
||||
// Should not error — even if node data is empty for the filtered rig
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--rig with unknown rig name exits 1", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--rig", "nonexistent"]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join(" ")).toContain("not found");
|
||||
});
|
||||
|
||||
it("--threshold filters seats but keeps unknown and stale visible", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "80", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const sessions = json.seats.map((s: { session: string }) => s.session);
|
||||
// 85% critical (above threshold) + 50% stale (always visible) + unknown (always visible)
|
||||
expect(sessions).toContain("dev-impl@test-rig");
|
||||
expect(sessions).toContain("dev-synth@test-rig"); // stale — always visible
|
||||
expect(sessions).toContain("dev-qa@test-rig"); // unknown — always visible
|
||||
});
|
||||
|
||||
it("--threshold abc rejects with nonzero exit", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "abc"]);
|
||||
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(errors.join(" ")).toContain("integer percentage");
|
||||
});
|
||||
|
||||
it("--threshold -1 rejects with nonzero exit", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--threshold", "-1"]);
|
||||
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("low + fresh = ok; low + stale = stale (never ok)", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
|
||||
// 50% (low urgency) + stale freshness => status must be "stale", NOT "ok"
|
||||
expect(stale.status).toBe("stale");
|
||||
expect(stale.displayStatus).toBe("stale");
|
||||
});
|
||||
|
||||
it("unknown seats have honest unknown status", async () => {
|
||||
const cmd = contextCommand(makeDeps());
|
||||
await cmd.parseAsync(["node", "rig", "--json"]);
|
||||
|
||||
const json = JSON.parse(logs.join(""));
|
||||
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
|
||||
expect(unknown.usedPercentage).toBeNull();
|
||||
expect(unknown.urgency).toBe("unknown");
|
||||
expect(unknown.status).toBe("unknown");
|
||||
expect(unknown.fresh).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user