mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat: native rig context command + dashboard context prominence
H44 Slice 2: context-usage as a first-class operator surface. CLI: new `rig context` command with two-axis status model (urgency x freshness) matching the shipped rigx-context wrapper. Flags: --rig, --threshold (keeps unknown + stale visible), --refresh (triggers daemon re-sample), --json. Sort: CRITICAL > WARNING > stale > ok > unknown. Only low+fresh = ok; low+stale = stale (not ok). Human table + fleet summary. Daemon: - FRESHNESS_THRESHOLD_MS updated from 120_000 (2min) to 600_000 (10min) per PM spec — aligns CLI/UI/daemon freshness semantics. - contextMonitor wired through AppDeps so routes can call pollOnce() for --refresh plumbing. - GET /api/rigs/:id/nodes?refresh=true triggers pollOnce before inventory projection; 502 on refresh failure (honest, non-silent). UI: - RigNode.tsx: context percentage enlarged from 8px badge to prominent 16px bold number; color-coded (green <60 / amber 60-80 / red >=80); stale samples at 50% opacity; unknown shows "?" instead of "CTX unknown". - Explorer.tsx: context percentage indicator added per agent row; replaces runtime label when context data available; same color coding + stale opacity. - useNodeInventory.ts: contextUsage type added to NodeInventoryEntry interface. Files: 10 across CLI/daemon/UI. No new /api/context endpoint — reuses existing node inventory routes per spec. Gates: daemon 1812/1812; CLI 446/447 (preexisting chatroom); UI 396/396; tsc clean across all 3 workspaces. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
efa6f41575
commit
bad82b3a7b
10 files changed
+340
-15
No files matched your search
@@ -0,0 +1,271 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface ContextDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
canonicalSessionName: string | null;
|
||||
runtime: string | null;
|
||||
contextUsage?: {
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
source: string | null;
|
||||
availability: string | null;
|
||||
sampledAt: string | null;
|
||||
fresh: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
session: string;
|
||||
rig: string;
|
||||
logicalId: string;
|
||||
runtime: string;
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
urgency: "critical" | "warning" | "low" | "unknown";
|
||||
freshness: "fresh" | "stale" | "none";
|
||||
status: string;
|
||||
displayStatus: string;
|
||||
source: string | null;
|
||||
availability: string;
|
||||
sampledAt: string | null;
|
||||
staleness: string;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
const FRESHNESS_THRESHOLD_S = 600; // 10 minutes
|
||||
|
||||
function ageLabel(sampledAt: string | null): string {
|
||||
if (!sampledAt) return "\u2014";
|
||||
const age = Math.max(0, Math.floor((Date.now() - new Date(sampledAt).getTime()) / 1000));
|
||||
if (age < 60) return "<1m ago";
|
||||
const minutes = Math.floor(age / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function computeFreshness(sampledAt: string | null): "fresh" | "stale" | "none" {
|
||||
if (!sampledAt) return "none";
|
||||
const age = (Date.now() - new Date(sampledAt).getTime()) / 1000;
|
||||
return age <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
|
||||
}
|
||||
|
||||
function analyze(node: NodeEntry): Seat {
|
||||
const ctx = node.contextUsage ?? {} as Partial<NonNullable<NodeEntry["contextUsage"]>>;
|
||||
const used = ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null;
|
||||
const remaining = ctx.remainingPercentage != null ? Math.round(ctx.remainingPercentage) : null;
|
||||
const window = ctx.contextWindowSize ?? null;
|
||||
const sampledAt = ctx.sampledAt ?? null;
|
||||
const source = ctx.source ?? null;
|
||||
const availability = ctx.availability ?? (used != null ? "known" : "unknown");
|
||||
|
||||
if (used == null) {
|
||||
return {
|
||||
session: node.canonicalSessionName ?? "",
|
||||
rig: node.rigName ?? "",
|
||||
logicalId: node.logicalId ?? "",
|
||||
runtime: node.runtime ?? "unknown",
|
||||
usedPercentage: null, remainingPercentage: null, contextWindowSize: null,
|
||||
urgency: "unknown", freshness: "none", status: "unknown", displayStatus: "unknown",
|
||||
source, availability, sampledAt, staleness: "\u2014", fresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const urgency: "critical" | "warning" | "low" = used >= 80 ? "critical" : used >= 60 ? "warning" : "low";
|
||||
const freshness = computeFreshness(sampledAt);
|
||||
let status: string;
|
||||
let displayStatus: string;
|
||||
|
||||
if (urgency === "critical") {
|
||||
status = freshness === "fresh" ? "critical" : "critical_stale";
|
||||
displayStatus = freshness === "fresh" ? "CRITICAL" : "CRITICAL (stale)";
|
||||
} else if (urgency === "warning") {
|
||||
status = freshness === "fresh" ? "warning" : "warning_stale";
|
||||
displayStatus = freshness === "fresh" ? "WARNING" : "WARNING (stale)";
|
||||
} else if (freshness === "fresh") {
|
||||
status = "ok";
|
||||
displayStatus = "ok";
|
||||
} else {
|
||||
status = "stale";
|
||||
displayStatus = "stale";
|
||||
}
|
||||
|
||||
return {
|
||||
session: node.canonicalSessionName ?? "",
|
||||
rig: node.rigName ?? "",
|
||||
logicalId: node.logicalId ?? "",
|
||||
runtime: node.runtime ?? "unknown",
|
||||
usedPercentage: used, remainingPercentage: remaining, contextWindowSize: window,
|
||||
urgency, freshness, status, displayStatus,
|
||||
source, availability, sampledAt, staleness: ageLabel(sampledAt), fresh: freshness === "fresh",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
critical: 0, critical_stale: 0,
|
||||
warning: 1, warning_stale: 1,
|
||||
stale: 2, ok: 3, unknown: 4,
|
||||
};
|
||||
|
||||
function sortSeats(seats: Seat[]): Seat[] {
|
||||
return seats.sort((a, b) => {
|
||||
const ra = STATUS_RANK[a.status] ?? 9;
|
||||
const rb = STATUS_RANK[b.status] ?? 9;
|
||||
if (ra !== rb) return ra - rb;
|
||||
const pa = a.usedPercentage ?? -1;
|
||||
const pb = b.usedPercentage ?? -1;
|
||||
if (pa !== pb) return pb - pa; // higher percentage first
|
||||
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
|
||||
return a.session.localeCompare(b.session);
|
||||
});
|
||||
}
|
||||
|
||||
function isVisible(seat: Seat, threshold: number | null): boolean {
|
||||
if (threshold == null) return true;
|
||||
if (seat.status === "unknown") return true;
|
||||
if (seat.freshness === "stale") return true;
|
||||
return seat.usedPercentage != null && seat.usedPercentage >= threshold;
|
||||
}
|
||||
|
||||
export function contextCommand(depsOverride?: ContextDeps): Command {
|
||||
const cmd = new Command("context")
|
||||
.description("Show context-usage across running agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig context Show all seats with context usage
|
||||
rig context --rig openrig-pm Show one rig
|
||||
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
|
||||
rig context --refresh Re-sample context before displaying
|
||||
rig context --json JSON output for agents`);
|
||||
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
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("--refresh", "Re-sample context usage before displaying")
|
||||
.action(async (opts: { json?: boolean; rig?: string; threshold?: number; refresh?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
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");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
try {
|
||||
// Get rig list to iterate
|
||||
const psResult = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
|
||||
const rigs = psResult.data ?? [];
|
||||
const targetRigs = opts.rig
|
||||
? rigs.filter((r) => r.name === opts.rig)
|
||||
: rigs;
|
||||
|
||||
if (opts.rig && targetRigs.length === 0) {
|
||||
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh once globally if requested (pollOnce samples all active Claude sessions)
|
||||
let refreshFailed = false;
|
||||
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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch node inventory per rig (without refresh — already done globally)
|
||||
const allNodes: NodeEntry[] = [];
|
||||
for (const rig of targetRigs) {
|
||||
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
|
||||
if (Array.isArray(nodesResult.data)) {
|
||||
allNodes.push(...nodesResult.data);
|
||||
}
|
||||
}
|
||||
|
||||
const seats = sortSeats(
|
||||
allNodes
|
||||
.map(analyze)
|
||||
.filter((s) => isVisible(s, opts.threshold ?? null))
|
||||
);
|
||||
|
||||
const summary = {
|
||||
total: seats.length,
|
||||
known: seats.filter((s) => s.usedPercentage != null).length,
|
||||
critical: seats.filter((s) => s.urgency === "critical").length,
|
||||
warning: seats.filter((s) => s.urgency === "warning").length,
|
||||
ok: seats.filter((s) => s.status === "ok").length,
|
||||
stale: seats.filter((s) => s.freshness === "stale").length,
|
||||
unknown: seats.filter((s) => s.status === "unknown").length,
|
||||
};
|
||||
|
||||
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));
|
||||
return;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
|
||||
console.log(title);
|
||||
console.log(
|
||||
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
|
||||
);
|
||||
for (const seat of seats) {
|
||||
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
|
||||
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
|
||||
console.log(
|
||||
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
`FLEET SUMMARY: ${summary.known}/${summary.total} known | ` +
|
||||
`${summary.critical} CRITICAL | ${summary.warning} WARNING | ` +
|
||||
`${summary.ok} ok | ${summary.stale} stale | ${summary.unknown} unknown`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { removeCommand } from "./commands/remove.js";
|
||||
import { shrinkCommand } from "./commands/shrink.js";
|
||||
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { contextCommand } from "./commands/context.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -136,6 +137,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
|
||||
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
|
||||
program.addCommand(setupCommand());
|
||||
program.addCommand(contextCommand());
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync, existsSync } from "node:fs";
|
||||
import type { ContextUsage, ContextUnknownReason } from "./types.js";
|
||||
|
||||
/** Freshness threshold: samples older than this are considered stale for compact displays. */
|
||||
export const FRESHNESS_THRESHOLD_MS = 120_000; // 2 minutes
|
||||
export const FRESHNESS_THRESHOLD_MS = 600_000; // 10 minutes per PM spec
|
||||
|
||||
export interface ContextUsageStoreOpts {
|
||||
stateDir: string;
|
||||
|
||||
@@ -31,11 +31,29 @@ sessionsRoutes.get("/", (c) => {
|
||||
});
|
||||
|
||||
// GET /api/rigs/:rigId/nodes — node inventory projection
|
||||
nodesRoutes.get("/", (c) => {
|
||||
// ?refresh=true triggers a context-monitor re-sample before responding
|
||||
nodesRoutes.get("/", async (c) => {
|
||||
const rigId = c.req.param("rigId")!;
|
||||
const deps = getDeps(c);
|
||||
const rig = deps.rigRepo.getRig(rigId);
|
||||
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rig ps` }, 404);
|
||||
|
||||
const refresh = c.req.query("refresh") === "true";
|
||||
if (refresh) {
|
||||
const monitor = c.get("contextMonitor" as never) as { pollOnce(): Promise<void> } | undefined;
|
||||
if (monitor) {
|
||||
try {
|
||||
await monitor.pollOnce();
|
||||
} catch (err) {
|
||||
return c.json({
|
||||
error: "Context refresh failed. Stale data may be returned.",
|
||||
code: "context_refresh_failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}, 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
|
||||
const inventory = contextUsageStore
|
||||
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore)
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AppDeps {
|
||||
specLibraryService?: SpecLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
|
||||
contextMonitor?: { pollOnce(): Promise<void> };
|
||||
nodeCmuxService?: import("./domain/node-cmux-service.js").NodeCmuxService;
|
||||
serviceOrchestrator?: import("./domain/service-orchestrator.js").ServiceOrchestrator;
|
||||
composeAdapter?: import("./adapters/compose-services-adapter.js").ComposeServicesAdapter;
|
||||
@@ -236,6 +237,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
c.set("contextUsageStore" as never, deps.contextUsageStore);
|
||||
c.set("contextMonitor" as never, deps.contextMonitor);
|
||||
c.set("nodeCmuxService" as never, deps.nodeCmuxService);
|
||||
c.set("serviceOrchestrator" as never, deps.serviceOrchestrator);
|
||||
c.set("composeAdapter" as never, deps.composeAdapter);
|
||||
|
||||
@@ -399,11 +399,13 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
}
|
||||
} catch { /* best-effort — reference docs are not critical to daemon operation */ }
|
||||
|
||||
const app = createApp(deps);
|
||||
|
||||
// Context monitor — caller (index.ts) starts polling after listen
|
||||
// Context monitor — constructed before createApp so routes can access pollOnce for refresh.
|
||||
// Caller (index.ts) starts polling after listen.
|
||||
const { ContextMonitor } = await import("./domain/context-monitor.js");
|
||||
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter);
|
||||
deps.contextMonitor = contextMonitor;
|
||||
|
||||
const app = createApp(deps);
|
||||
|
||||
return { app, db, deps, contextMonitor };
|
||||
}
|
||||
@@ -174,7 +174,7 @@ describe("ContextUsageStore", () => {
|
||||
// T9: Freshness threshold is centralized
|
||||
it("FRESHNESS_THRESHOLD_MS is exported and used consistently", () => {
|
||||
expect(typeof FRESHNESS_THRESHOLD_MS).toBe("number");
|
||||
expect(FRESHNESS_THRESHOLD_MS).toBe(120_000);
|
||||
expect(FRESHNESS_THRESHOLD_MS).toBe(600_000);
|
||||
});
|
||||
|
||||
// T10: context_usage row cascades on node delete
|
||||
|
||||
@@ -165,9 +165,23 @@ function PodBranch({
|
||||
testId={`node-icon-${node.logicalId}`}
|
||||
/>
|
||||
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase text-stone-400">
|
||||
{node.nodeKind === "infrastructure" ? "INFRA" : (node.runtime ?? "").replace("claude-code", "claude")}
|
||||
</span>
|
||||
{node.contextUsage?.availability === "known" && typeof node.contextUsage.usedPercentage === "number" ? (
|
||||
<span
|
||||
className={`ml-auto shrink-0 font-mono text-[10px] font-bold ${
|
||||
node.contextUsage.usedPercentage >= 80 ? "text-red-600" :
|
||||
node.contextUsage.usedPercentage >= 60 ? "text-amber-600" :
|
||||
"text-green-700"
|
||||
}${!node.contextUsage.fresh ? " opacity-50" : ""}`}
|
||||
data-testid={`context-indicator-${node.logicalId}`}
|
||||
title={node.contextUsage.fresh ? `${node.contextUsage.usedPercentage}% context (fresh)` : `${node.contextUsage.usedPercentage}% context (stale)`}
|
||||
>
|
||||
{node.contextUsage.usedPercentage}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase text-stone-400">
|
||||
{node.nodeKind === "infrastructure" ? "INFRA" : (node.runtime ?? "").replace("claude-code", "claude")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -194,14 +194,22 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context usage badge */}
|
||||
{data.contextAvailability === "known" && data.contextFresh && typeof data.contextUsedPercentage === "number" ? (
|
||||
<div className="font-mono text-[8px] text-stone-500" data-testid="context-badge">
|
||||
CTX {data.contextUsedPercentage}%
|
||||
{/* Context usage — prominent big number per founder directive */}
|
||||
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
|
||||
<div
|
||||
className={`font-mono text-base font-bold leading-tight ${
|
||||
data.contextUsedPercentage >= 80 ? "text-red-600" :
|
||||
data.contextUsedPercentage >= 60 ? "text-amber-600" :
|
||||
"text-green-700"
|
||||
}${!data.contextFresh ? " opacity-50" : ""}`}
|
||||
data-testid="context-badge"
|
||||
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
|
||||
>
|
||||
{data.contextUsedPercentage}%
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[8px] text-stone-300" data-testid="context-badge-unknown">
|
||||
CTX unknown
|
||||
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface NodeInventoryEntry {
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
latestError: string | null;
|
||||
contextUsage?: {
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
contextWindowSize: number | null;
|
||||
availability: string | null;
|
||||
sampledAt: string | null;
|
||||
fresh: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
|
||||
|
||||
Reference in new issue
Block a user