mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
OPR.0.4.0.26: dedupe node-list payload at source (recoveryGuidance + currentUsage)
Slim the daemon node-LIST payload so even --full and the UI consumers stop paying for two source hogs, while preserving full data on the detail/whoami paths and breaking no consumer. FR-1: getNodeInventory no longer inlines per-node recoveryGuidance prose in the LIST (it duplicated ~47KB across nodes and no node-list consumer read it). The full guidance is recomputed on the single-node detail path (getNodeDetail). FR-2/FR-3: getNodeInventoryWithContext drops the heavy currentUsage blob from the LIST contextUsage (a serialized per-node usage payload, ~79KB across a large fleet) while keeping every scalar the ring/table/filter consumers use. The full currentUsage stays on the detail (getNodeDetailWithContext) and whoami paths. FR-4: no consumer migration needed. Verified at current main: - the only recoveryGuidance LIST-shaped reader (cli restore.ts) actually reads the restore-result payload, a separate builder - unaffected; - no cli/ui node-list consumer reads currentUsage (only the detail hook type); - all node-list contextUsage consumers read scalars only, preserved. Tests: migrated the 3 node-inventory recoveryGuidance assertions to the detail path; added dedup-contract tests (list nulls both fields + keeps scalars; detail keeps full currentUsage + recoveryGuidance) and an AC-1 list-vs-detail JSON slimming/relocation proof. 43/43 node-inventory tests green; daemon + cli + ui typecheck clean. No parser/UI/convention change. Detail/whoami unchanged (already full). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
dba959e2cd
commit
d2115e9d19
2 files changed
+154
-27
No files matched your search
@@ -419,13 +419,12 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
handoverAt: row.handover_at,
|
||||
tmuxAttachCommand: row.binding_attachment_type === "tmux" && row.session_name ? `tmux attach -t ${row.session_name}` : null,
|
||||
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile),
|
||||
recoveryGuidance: computeRecoveryGuidance({
|
||||
runtime: row.runtime,
|
||||
resumeToken: row.resume_token,
|
||||
cwd: row.cwd,
|
||||
sessionName: row.session_name,
|
||||
codexConfigProfile: row.codex_config_profile,
|
||||
}),
|
||||
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
|
||||
// payload. It duplicated ~47KB of templated prose across all nodes and
|
||||
// no node-list consumer reads it. The full guidance is recomputed on
|
||||
// the single-node detail path (getNodeDetail / GET
|
||||
// /api/rigs/:rigId/nodes/:logicalId) — relocation, not loss.
|
||||
recoveryGuidance: null,
|
||||
latestError: row.startup_status === "ready" ? null : getLatestError(db, rigId, row.node_id),
|
||||
// Extended fields
|
||||
model: row.model,
|
||||
@@ -611,6 +610,16 @@ export function getNodeDetail(
|
||||
|
||||
return {
|
||||
...entry,
|
||||
// OPR.0.4.0.26: the LIST omits recoveryGuidance to stay slim; the
|
||||
// single-node detail recomputes the full guidance from the entry's
|
||||
// resume fields (relocation of the per-node prose, not loss).
|
||||
recoveryGuidance: computeRecoveryGuidance({
|
||||
runtime: entry.runtime,
|
||||
resumeToken: entry.resumeToken,
|
||||
cwd: entry.cwd,
|
||||
sessionName: entry.canonicalSessionName,
|
||||
codexConfigProfile: entry.codexConfigProfile,
|
||||
}),
|
||||
binding,
|
||||
startupFiles,
|
||||
startupActions,
|
||||
@@ -650,7 +659,13 @@ export function getNodeInventoryWithContext(
|
||||
|
||||
return entries.map((e) => {
|
||||
const nodeId = nodeIdByLogicalId.get(e.logicalId) ?? "";
|
||||
return { ...e, contextUsage: contextMap.get(nodeId) ?? contextUsageStore.unknownUsage("no_data") };
|
||||
const usage = contextMap.get(nodeId) ?? contextUsageStore.unknownUsage("no_data");
|
||||
// OPR.0.4.0.26: drop the heavy `currentUsage` blob from the LIST
|
||||
// contextUsage (a serialized per-node usage payload, ~79KB across a
|
||||
// large fleet; no node-list consumer reads it). All scalars are kept so
|
||||
// the ring/table/filter consumers are unaffected. The full currentUsage
|
||||
// remains on the detail/whoami path (getNodeDetailWithContext, whoami).
|
||||
return { ...e, contextUsage: { ...usage, currentUsage: null } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { getNodeInventory, getNodeDetail } from "../src/domain/node-inventory.js";
|
||||
import { getNodeInventory, getNodeDetail, getNodeInventoryWithContext, getNodeDetailWithContext } from "../src/domain/node-inventory.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { ContextUsage } from "../src/domain/types.js";
|
||||
import type { ContextUsageStore } from "../src/domain/context-usage-store.js";
|
||||
|
||||
function seedPodAwareRig(db: Database.Database, opts?: { rigName?: string }) {
|
||||
const rigName = opts?.rigName ?? "test-rig";
|
||||
@@ -164,14 +166,17 @@ describe("Node Inventory Projection", () => {
|
||||
resumeToken: "abc-123-def",
|
||||
});
|
||||
|
||||
const entries = getNodeInventory(db, "rig-1");
|
||||
const entry = entries.find((e) => e.logicalId === "dev.impl");
|
||||
expect(entry?.recoveryGuidance?.summary).toContain("native Claude resume");
|
||||
expect(entry?.recoveryGuidance?.commands).toContain("claude --resume 'abc-123-def' --name 'dev-impl@test-rig'");
|
||||
expect(entry?.recoveryGuidance?.commands).toContain("cd /project");
|
||||
expect(entry?.recoveryGuidance?.commands).toContain("claude --resume");
|
||||
expect(entry?.recoveryGuidance?.notes).toContain("Look for session name: dev-impl@test-rig");
|
||||
expect(entry?.recoveryGuidance?.notes).toContain("Choose the full conversation option, not summary.");
|
||||
// OPR.0.4.0.26: recoveryGuidance is relocated off the LIST onto the
|
||||
// single-node detail path. The LIST entry no longer inlines it.
|
||||
const entry = getNodeInventory(db, "rig-1").find((e) => e.logicalId === "dev.impl");
|
||||
expect(entry?.recoveryGuidance).toBeNull();
|
||||
const detail = getNodeDetail(db, "rig-1", "dev.impl");
|
||||
expect(detail?.recoveryGuidance?.summary).toContain("native Claude resume");
|
||||
expect(detail?.recoveryGuidance?.commands).toContain("claude --resume 'abc-123-def' --name 'dev-impl@test-rig'");
|
||||
expect(detail?.recoveryGuidance?.commands).toContain("cd /project");
|
||||
expect(detail?.recoveryGuidance?.commands).toContain("claude --resume");
|
||||
expect(detail?.recoveryGuidance?.notes).toContain("Look for session name: dev-impl@test-rig");
|
||||
expect(detail?.recoveryGuidance?.notes).toContain("Choose the full conversation option, not summary.");
|
||||
});
|
||||
|
||||
it("recoveryGuidance for Codex without token uses workspace-local picker fallback", () => {
|
||||
@@ -182,13 +187,14 @@ describe("Node Inventory Projection", () => {
|
||||
).run("node-codex", "rig-2", "dev.qa", "codex", "/workspace/app", "pod-2", "local:agents/qa", "default", "qa", "1.0.0", "hash");
|
||||
seedSession(db, "node-codex", "dev-qa@test-rig");
|
||||
|
||||
const entries = getNodeInventory(db, "rig-2");
|
||||
const entry = entries.find((e) => e.logicalId === "dev.qa");
|
||||
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "dev.qa");
|
||||
expect(entry?.resumeCommand).toBeNull();
|
||||
expect(entry?.recoveryGuidance?.summary).toContain("codex resume --last with posture flags");
|
||||
expect(entry?.recoveryGuidance?.commands).toEqual(["cd /workspace/app", "codex -a on-request -s danger-full-access resume --last"]);
|
||||
expect(entry?.recoveryGuidance?.notes).toContain("Use workspace and recent prompt text to identify the right conversation.");
|
||||
expect(entry?.recoveryGuidance?.notes).toContain("If the identity anchor was captured, the picker may include: dev-qa@test-rig");
|
||||
expect(entry?.recoveryGuidance).toBeNull(); // relocated to detail
|
||||
const detail = getNodeDetail(db, "rig-2", "dev.qa");
|
||||
expect(detail?.recoveryGuidance?.summary).toContain("codex resume --last with posture flags");
|
||||
expect(detail?.recoveryGuidance?.commands).toEqual(["cd /workspace/app", "codex -a on-request -s danger-full-access resume --last"]);
|
||||
expect(detail?.recoveryGuidance?.notes).toContain("Use workspace and recent prompt text to identify the right conversation.");
|
||||
expect(detail?.recoveryGuidance?.notes).toContain("If the identity anchor was captured, the picker may include: dev-qa@test-rig");
|
||||
});
|
||||
|
||||
it("resumeCommand and recoveryGuidance preserve Codex config profile", () => {
|
||||
@@ -202,13 +208,15 @@ describe("Node Inventory Projection", () => {
|
||||
resumeToken: "sess-456",
|
||||
});
|
||||
|
||||
const entries = getNodeInventory(db, "rig-2");
|
||||
const entry = entries.find((e) => e.logicalId === "platform.mac-admin");
|
||||
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "platform.mac-admin");
|
||||
|
||||
expect(entry?.codexConfigProfile).toBe("sysadmin");
|
||||
expect(entry?.resumeCommand).toBe("codex -p 'sysadmin' resume 'sess-456'");
|
||||
expect(entry?.recoveryGuidance?.commands).toContain("codex -p 'sysadmin' resume 'sess-456'");
|
||||
expect(entry?.recoveryGuidance?.notes).toContain("Preserve Codex config profile: sysadmin");
|
||||
expect(entry?.recoveryGuidance).toBeNull(); // relocated to detail
|
||||
const detail = getNodeDetail(db, "rig-2", "platform.mac-admin");
|
||||
expect(detail?.codexConfigProfile).toBe("sysadmin");
|
||||
expect(detail?.recoveryGuidance?.commands).toContain("codex -p 'sysadmin' resume 'sess-456'");
|
||||
expect(detail?.recoveryGuidance?.notes).toContain("Preserve Codex config profile: sysadmin");
|
||||
});
|
||||
|
||||
// Test 7: startupStatus reflects session startup_status column
|
||||
@@ -755,3 +763,107 @@ describe("Node Inventory Projection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.0.26 — node-LIST payload source dedupe. The LIST drops the heavy
|
||||
// per-node recoveryGuidance prose and the currentUsage blob; the full data
|
||||
// is RELOCATED (not deleted) onto the single-node detail / whoami path.
|
||||
describe("OPR.0.4.0.26 — node-list payload source dedupe", () => {
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createFullTestDb(); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
// A quote-free marker inside the blob so substring checks survive JSON
|
||||
// escaping when the value is serialized inside a larger payload.
|
||||
const HEAVY_BLOB_MARKER = "ZZHEAVYCURRENTUSAGEBLOBZZ";
|
||||
const HEAVY_CURRENT_USAGE = JSON.stringify({ model_context_window: 258400, blob: HEAVY_BLOB_MARKER + "x".repeat(4000) });
|
||||
|
||||
function fullUsage(): ContextUsage {
|
||||
return {
|
||||
availability: "known",
|
||||
reason: null,
|
||||
source: "codex_token_count_jsonl",
|
||||
usedPercentage: 42,
|
||||
remainingPercentage: 58,
|
||||
contextWindowSize: 200000,
|
||||
totalInputTokens: 1000,
|
||||
totalOutputTokens: 2000,
|
||||
currentUsage: HEAVY_CURRENT_USAGE,
|
||||
transcriptPath: null,
|
||||
sessionId: "sess-x",
|
||||
sessionName: "dev-impl@test-rig",
|
||||
sampledAt: "2026-06-20T00:00:00.000Z",
|
||||
fresh: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Stub store: returns the full usage for every node so we can observe the
|
||||
// list-vs-detail split without persisting real samples.
|
||||
function stubStore(): ContextUsageStore {
|
||||
return {
|
||||
getForNodes: (entries: Array<{ nodeId: string }>) =>
|
||||
new Map(entries.map((e) => [e.nodeId, fullUsage()])),
|
||||
getForNode: () => fullUsage(),
|
||||
unknownUsage: () => ({ ...fullUsage(), availability: "unknown", reason: "no_data" }),
|
||||
} as unknown as ContextUsageStore;
|
||||
}
|
||||
|
||||
it("LIST omits recoveryGuidance (relocated to detail)", () => {
|
||||
seedPodAwareRig(db);
|
||||
seedSession(db, "node-1", "dev-impl@test-rig", { resumeToken: "abc-123-def" });
|
||||
|
||||
const listEntry = getNodeInventory(db, "rig-1").find((e) => e.logicalId === "dev.impl");
|
||||
expect(listEntry?.recoveryGuidance).toBeNull();
|
||||
|
||||
const detail = getNodeDetail(db, "rig-1", "dev.impl");
|
||||
expect(detail?.recoveryGuidance).not.toBeNull();
|
||||
expect(detail?.recoveryGuidance?.summary).toBeTruthy();
|
||||
});
|
||||
|
||||
it("LIST contextUsage drops currentUsage but keeps every scalar", () => {
|
||||
seedPodAwareRig(db);
|
||||
seedSession(db, "node-1", "dev-impl@test-rig");
|
||||
|
||||
const listEntry = getNodeInventoryWithContext(db, "rig-1", stubStore())
|
||||
.find((e) => e.logicalId === "dev.impl");
|
||||
const ctx = listEntry?.contextUsage;
|
||||
// The heavy blob is gone from the LIST...
|
||||
expect(ctx?.currentUsage).toBeNull();
|
||||
// ...but the scalars the ring/table/filter consumers use are intact.
|
||||
expect(ctx?.usedPercentage).toBe(42);
|
||||
expect(ctx?.remainingPercentage).toBe(58);
|
||||
expect(ctx?.contextWindowSize).toBe(200000);
|
||||
expect(ctx?.totalInputTokens).toBe(1000);
|
||||
expect(ctx?.totalOutputTokens).toBe(2000);
|
||||
expect(ctx?.sampledAt).toBe("2026-06-20T00:00:00.000Z");
|
||||
expect(ctx?.fresh).toBe(true);
|
||||
expect(ctx?.source).toBe("codex_token_count_jsonl");
|
||||
expect(ctx?.availability).toBe("known");
|
||||
});
|
||||
|
||||
it("DETAIL contextUsage retains the full currentUsage (relocation, not loss)", () => {
|
||||
seedPodAwareRig(db);
|
||||
seedSession(db, "node-1", "dev-impl@test-rig");
|
||||
|
||||
const detail = getNodeDetailWithContext(db, "rig-1", "dev.impl", stubStore());
|
||||
expect(detail?.contextUsage?.currentUsage).toBe(HEAVY_CURRENT_USAGE);
|
||||
// Detail also carries full recoveryGuidance.
|
||||
expect(detail?.recoveryGuidance).not.toBeNull();
|
||||
});
|
||||
|
||||
it("AC-1: LIST JSON omits the heavy blobs; DETAIL JSON keeps them (slimmed + relocated)", () => {
|
||||
seedPodAwareRig(db);
|
||||
seedSession(db, "node-1", "dev-impl@test-rig", { resumeToken: "abc-123-def" });
|
||||
|
||||
const listJson = JSON.stringify(getNodeInventoryWithContext(db, "rig-1", stubStore()));
|
||||
// The heavy currentUsage blob and the per-node guidance prose are NOT in
|
||||
// the LIST payload — the two source hogs no longer dominate.
|
||||
expect(listJson).not.toContain(HEAVY_BLOB_MARKER);
|
||||
expect(listJson).not.toContain("Choose the full conversation option");
|
||||
expect(listJson).toContain('"recoveryGuidance":null');
|
||||
|
||||
const detailJson = JSON.stringify(getNodeDetailWithContext(db, "rig-1", "dev.impl", stubStore()));
|
||||
// The full data remains retrievable on the single-node DETAIL path.
|
||||
expect(detailJson).toContain(HEAVY_BLOB_MARKER);
|
||||
expect(detailJson).toContain("Choose the full conversation option");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user