feat(pl-019): topology activity indicators v0 (6 items)

PL-019 Topology Activity Indicators v0 — operator sees at-a-glance
which agents are working, what qitems they're on, and how the topology
is moving. Single ambitious vertical; six items shipped in one batch.

Item 1 (CLI convenience): rig ps --nodes --filter agentActivity.state=<state>
extends the --filter allow-list with a nested key + enum-validated value.
--active is sugar for agentActivity.state=running. Combining --active
with --filter is rejected. Three-part error on invalid value
(what failed / what's allowed / what to do). Fixes a pre-existing gap
where bare 'agentActivity' was in ALLOWED_FILTER_KEYS but applyNodeFilter
didn't actually evaluate it (silent no-op).

Item 2 (graph node color): RigNode dot now driven by agentActivity.state
via a new shared activity-visuals helper. Restrained palette per orch
design: running = warm green/teal (emerald-500) + subtle 1.8s opacity
pulse; needs_input = static amber-500 (the eye-catcher); idle = calm
slate-400; unknown = desaturated stone-300. No backwards-compat shim
for the old startup-status dot color — getStartupStatus* helpers
deleted. ATTN/FAILED badges retained as a separate signal (startup-state
answers a different question than activity-state). Stale-sample badge
when sampledAt > ~30s threshold.

Item 3 (Explorer indicator): NodeActivityIndicator added next to the
existing startup-status icon on every Explorer row. Same palette as
item 2. 'Owns active work' bullet appears when currentQitems is
non-empty; tooltip lists short ULID tail + body excerpt per qitem.

Item 4 (graph payload): InventoryOverlay carries agentActivity through
to RFNodeData; routes/rigs.ts attaches activity via attachAgentActivity
at graph-payload time so UI consumers don't need a separate fetch.

Item 5 (active-qitem read-side join): loadCurrentQitemsForSessions
helper exported from routes/rigs.ts joins queue_items WHERE state =
'in-progress' AND destination_session IN (...) ORDER BY ts_updated DESC.
Cap of 3 qitems per node (multi-claim is rare); body excerpted at 80
chars for phone-friendly surfaces. Same enrichment layered onto
routes/sessions.ts node-detail. Cheap by virtue of the existing
idx_queue_items_destination_state index.

Item 6 (edge animation): useTopologyEdgeActivity hook subscribes to
/api/events SSE and tracks recent topology traffic by directed
(source, dest) pair across queue.created, queue.handed_off, and
qitem.fallback_routed. Edges between matched seats get the
edge-recent-traffic class for ~30s sustained emphasis (1.5x weight +
brighter) and edge-just-fired for a one-shot 1.2s pulse on each
firing. mission_control.action_executed is intentionally not animated
at v0 because the event payload lacks a destination session pair.
Reduced-motion media query suppresses both animations.

Boundaries preserved: NO new schema, NO migrations, NO new event
types — v0 consumes the agentActivity primitive shipped at types.ts
and subscribes to events that already exist on PL-004 / PL-005 buses.
Mission Control surfaces (PL-005) and queue-repository (PL-004) are
read-only. PL-005 Phase B surfaces in the parallel worktree are
untouched.

Verification:
- daemon: 2625/2625 pass (was 2607 baseline + 18 new PL-019 cases:
  6 graph-projection-pl019, 9 rigs-routes-pl019-current-qitems, plus
  pre-existing rigs/sessions tests now exercise the new graph-payload
  enrichment via createFullTestDb extended through migration 025).
- ui: 432/432 pass (added 18 activity-visuals + 8 edge-activity
  normalize cases + 3 RigNode activity tests; updated 2 RigNode tests
  + 4 RigGraph SSE tests to find the rig-events EventSource by
  ?rigId= URL since RigGraph now opens two SSE connections).
- cli: 797/800 pass; the 3 failures (preflight env-probes, config
  resolver) reproduce on the unchanged Phase A baseline so they are
  pre-existing and unrelated to PL-019.
- tsc clean for daemon + cli + ui.
- cli-reference.md updated to document --active and the new
  agentActivity.state filter key.

Hard-gate row 9 (headed-browser dogfood with operator-confirmed
legibility on a real rig with multiple active seats) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md and feedback_velocity_proof_ownership_split.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 23:13:01 -07:00
1 parent ca4f02ead9
commit ee39768e8e
19 files changed
+1380 -66

No files matched your search

+3 -2
View File
@@ -269,7 +269,7 @@ Notes:
Usage:
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
Notes:
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
@@ -278,7 +278,8 @@ Notes:
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--filter <key=value>` accepts only `status`, `lifecycleState`, `name-prefix`, `name`. Unknown keys are rejected before any HTTP call with a clear error naming the supported list.
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
- Exit codes:
- `0` success
+66 -5
View File
@@ -55,7 +55,23 @@ 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"]);
//
// PL-019 item 1: extends the allow-list with the nested key `agentActivity.state`.
// This is the only nested filter key v0 supports; the parser checks for the
// dotted form explicitly. Bare `agentActivity` is intentionally NOT a filter
// key — it's an object, not a scalar; project it via --fields if you need it.
const ALLOWED_FILTER_KEYS = new Set([
"status",
"lifecycleState",
"name-prefix",
"name",
"agentActivity.state",
]);
// PL-019 item 1: when filter key is `agentActivity.state`, the value is
// validated against the AgentActivityState enum. Invalid values produce a
// three-part error (what failed / what's allowed / what to do).
const ALLOWED_AGENT_ACTIVITY_STATES = new Set(["running", "needs_input", "idle", "unknown"]);
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
// error with the supported list, mirroring the --filter rejection pattern.
@@ -104,6 +120,7 @@ interface PsCliOptions {
summary?: boolean;
filter?: string;
host?: string;
active?: boolean;
}
export interface PsDeps extends StatusDeps {
@@ -139,6 +156,17 @@ function parseFilter(filter: string): ParsedFilter | { error: string } {
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
// PL-019 item 1: agentActivity.state is enum-valued; reject invalid values
// up-front so operators don't get a silent empty result on a typo. Three-part
// shape per `feedback_smart_agents_no_bureaucracy.md`: what failed / what's
// allowed / what to do.
if (key === "agentActivity.state" && !ALLOWED_AGENT_ACTIVITY_STATES.has(value)) {
return {
error: `--filter agentActivity.state='${value}' is not a valid activity state. ` +
`Allowed: ${[...ALLOWED_AGENT_ACTIVITY_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields agentActivity --json' to see what daemon is reporting.`,
};
}
return { key, value };
}
@@ -172,6 +200,10 @@ function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
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;
// PL-019 item 1: agentActivity.state is node-level only; at the rig
// level it has no meaning, so the filter passes everything through and
// the user gets all rigs (the operator can `--nodes` to scope it).
if (filter.key === "agentActivity.state") return true;
return true;
});
}
@@ -183,6 +215,10 @@ function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[
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;
// PL-019 item 1: nested-key traversal for agentActivity.state. Nodes
// without an agentActivity attachment never match a non-unknown filter
// value (the daemon reports `unknown` when it has no signal — explicit).
if (filter.key === "agentActivity.state") return n.agentActivity?.state === filter.value;
return true;
});
}
@@ -278,12 +314,17 @@ Examples:
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
rig ps --nodes --active Show only nodes whose agentActivity.state == running (PL-019)
rig ps --nodes --filter agentActivity.state=running
Same as --active (the explicit form)
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
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.
--filter accepts: status, lifecycleState, name-prefix, name, agentActivity.state.
Other keys are rejected. agentActivity.state is node-level (use with --nodes);
allowed values: running, needs_input, idle, unknown.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
status, lifecycleState, uptime, latestSnapshot.
@@ -315,7 +356,8 @@ Exit codes:
.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")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.action(async (opts: PsCliOptions) => {
const deps = getDepsF();
@@ -335,9 +377,28 @@ Exit codes:
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
//
// PL-019 item 1: --active is sugar for --filter agentActivity.state=running.
// Combining --active with --filter is rejected (composition is
// ambiguous — pick one explicit form). Parity is verified end-to-end
// by the focused test: `--active` and the explicit filter must yield
// identical output on the same fixture.
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
console.error(
`--active and --filter cannot be combined. ` +
`--active is sugar for --filter agentActivity.state=running. ` +
`Pick one form, or compose by upgrading to --filter directly.`
);
process.exitCode = 1;
return;
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (opts.filter) {
const result = parseFilter(opts.filter);
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
+267
View File
@@ -0,0 +1,267 @@
// PL-019 item 1: --filter agentActivity.state=<state> + --active sugar.
// Focused tests; the existing ps.test.ts covers the broader filter
// machinery surface. We mirror its mock-daemon harness so this stays
// independent of any other slice in flight.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander throws on exitOverride; logged separately */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const NODE_RUNNING = {
rigId: "rig-1",
rigName: "demo",
logicalId: "alpha",
podId: "p-1",
canonicalSessionName: "demo-alpha",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "running" as const,
reason: "mid_work_pattern",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "Working on x",
},
};
const NODE_IDLE = {
rigId: "rig-1",
rigName: "demo",
logicalId: "beta",
podId: "p-1",
canonicalSessionName: "demo-beta",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "idle" as const,
reason: "idle_prompt",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "$ ",
},
};
const NODE_NEEDS_INPUT = {
rigId: "rig-1",
rigName: "demo",
logicalId: "gamma",
podId: "p-1",
canonicalSessionName: "demo-gamma",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "needs_input" as const,
reason: "approval_pending",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "Approve?",
},
};
const NODE_UNKNOWN = {
rigId: "rig-1",
rigName: "demo",
logicalId: "delta",
podId: "p-1",
canonicalSessionName: "demo-delta",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "unknown" as const,
reason: "capture_failed",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: null,
},
};
describe("PL-019 ps --filter agentActivity.state + --active", () => {
let server: http.Server;
let port: number;
let nodesByRig: Record<string, unknown[]>;
beforeAll(async () => {
nodesByRig = {};
server = http.createServer((req, res) => {
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/);
if (m && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(nodesByRig[decodeURIComponent(m[1]!)] ?? []));
} else if (req.url === "/api/ps" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ rigId: "rig-1", name: "demo", nodeCount: 4, runningCount: 4, status: "running", uptime: "1h", latestSnapshot: null }]));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(psCommand(runningDeps(port)));
return prog;
}
it("--filter agentActivity.state=running narrows to the running node", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("alpha");
expect(entries[0].agentActivity.state).toBe("running");
});
it("--active is identical to --filter agentActivity.state=running on same fixture", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const a = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
});
const b = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
});
expect(a.exitCode).toBeUndefined();
expect(b.exitCode).toBeUndefined();
expect(a.logs.join("")).toBe(b.logs.join(""));
});
it("--filter agentActivity.state=needs_input picks the amber-state node", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=needs_input"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("gamma");
});
it("--filter agentActivity.state=invalid fails fast with three-part error and exit 1", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=lol-no"]);
});
expect(exitCode).toBe(1);
const errOutput = errLogs.join("\n");
// What failed
expect(errOutput).toContain("agentActivity.state='lol-no'");
// What's allowed (sorted enum)
expect(errOutput).toContain("idle, needs_input, running, unknown");
// What to do (next-step pointer)
expect(errOutput).toContain("rig ps --nodes --fields agentActivity --json");
});
it("--active combined with --filter is rejected explicitly with exit 1", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active", "--filter", "status=running"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("--active and --filter cannot be combined");
});
it("--filter agentActivity.state=running excludes nodes whose agentActivity is missing", async () => {
const NODE_NO_ACTIVITY = {
...NODE_RUNNING,
logicalId: "epsilon",
canonicalSessionName: "demo-epsilon",
agentActivity: undefined,
};
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_NO_ACTIVITY];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("alpha");
});
});
+15 -1
View File
@@ -1,10 +1,16 @@
import type { RigWithRelations, Session, Binding, Pod } from "./types.js";
import type { RigWithRelations, Session, Binding, Pod, AgentActivity } from "./types.js";
export interface RigGraphInput extends RigWithRelations {
sessions: Session[];
pods?: Pod[];
}
export interface CurrentQitemSummary {
qitemId: string;
bodyExcerpt: string;
tier: string | null;
}
interface RFNodeData {
logicalId: string;
podLabel?: string | null;
@@ -27,6 +33,8 @@ interface RFNodeData {
contextUsedPercentage: number | null;
contextFresh: boolean;
contextAvailability: string;
agentActivity?: AgentActivity | null;
currentQitems?: CurrentQitemSummary[];
}
interface RFNode {
@@ -59,6 +67,8 @@ export interface InventoryOverlay {
contextUsedPercentage?: number | null;
contextFresh?: boolean;
contextAvailability?: string;
agentActivity?: AgentActivity | null;
currentQitems?: CurrentQitemSummary[];
}
export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: InventoryOverlay[]): ReactFlowGraph {
@@ -115,6 +125,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
contextUsedPercentage: overlay?.contextUsedPercentage ?? null,
contextFresh: overlay?.contextFresh ?? false,
contextAvailability: overlay?.contextAvailability ?? "unknown",
agentActivity: overlay?.agentActivity ?? null,
currentQitems: overlay?.currentQitems ?? [],
},
};
});
@@ -148,6 +160,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
contextUsedPercentage: null,
contextFresh: false,
contextAvailability: "unknown",
agentActivity: null,
currentQitems: [],
},
});
}
+70 -5
View File
@@ -1,11 +1,14 @@
import { Hono } from "hono";
import type Database from "better-sqlite3";
import type { RigRepository } from "../domain/rig-repository.js";
import type { SessionRegistry } from "../domain/session-registry.js";
import type { EventBus } from "../domain/event-bus.js";
import type { SnapshotRepository } from "../domain/snapshot-repository.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import { projectRigToGraph, type InventoryOverlay } from "../domain/graph-projection.js";
import { getNodeInventory, getNodeInventoryWithContext } from "../domain/node-inventory.js";
import { projectRigToGraph, type InventoryOverlay, type CurrentQitemSummary } from "../domain/graph-projection.js";
import { getNodeInventory, getNodeInventoryWithContext, attachAgentActivity } from "../domain/node-inventory.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
import { deriveRigLifecycleState } from "../domain/ps-projection.js";
import type { ContextUsageStore } from "../domain/context-usage-store.js";
import type { Pod, ExpansionPodFragment } from "../domain/types.js";
@@ -15,6 +18,43 @@ import type { SelfAttachService } from "../domain/self-attach-service.js";
export const rigsRoutes = new Hono();
// PL-019 item 5: read-side join helper. Returns map of
// destination_session → in-progress qitems (capped at MAX_QITEMS_PER_NODE
// per node), keyed by canonicalSessionName so the route can stitch into
// the InventoryOverlay. Body is excerpted to stay phone-friendly in
// tooltip / drawer surfaces (item 5's UI consumers).
const MAX_QITEMS_PER_NODE = 3;
const BODY_EXCERPT_MAX_CHARS = 80;
export function loadCurrentQitemsForSessions(
db: Database.Database,
sessionNames: string[]
): Map<string, CurrentQitemSummary[]> {
const out = new Map<string, CurrentQitemSummary[]>();
if (sessionNames.length === 0) return out;
const placeholders = sessionNames.map(() => "?").join(",");
const rows = db.prepare(
`SELECT qitem_id, destination_session, body, tier
FROM queue_items
WHERE state = 'in-progress' AND destination_session IN (${placeholders})
ORDER BY ts_updated DESC`
).all(...sessionNames) as Array<{ qitem_id: string; destination_session: string; body: string; tier: string | null }>;
for (const row of rows) {
const list = out.get(row.destination_session) ?? [];
if (list.length < MAX_QITEMS_PER_NODE) {
list.push({
qitemId: row.qitem_id,
bodyExcerpt: row.body.length > BODY_EXCERPT_MAX_CHARS
? `${row.body.slice(0, BODY_EXCERPT_MAX_CHARS)}…`
: row.body,
tier: row.tier,
});
}
out.set(row.destination_session, list);
}
return out;
}
function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionPodFragment | null {
if (!raw || typeof raw !== "object") return null;
const id = raw["id"];
@@ -145,22 +185,43 @@ rigsRoutes.get("/:id", (c) => {
return c.json(rig);
});
rigsRoutes.get("/:id/graph", (c) => {
rigsRoutes.get("/:id/graph", async (c) => {
const rig = getRepo(c).getRig(c.req.param("id"));
if (!rig) {
return c.json({ error: "rig not found" }, 404);
}
const rigId = c.req.param("id");
const sessions = getSessionRegistry(c).getSessionsForRig(rigId);
// Overlay inventory data for enriched graph fields
// Overlay inventory data for enriched graph fields.
const ctxStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const inventory = ctxStore
? getNodeInventoryWithContext(getRepo(c).db, rigId, ctxStore)
: getNodeInventory(getRepo(c).db, rigId);
// PL-019 item 4: enrich inventory with agentActivity at graph-payload time
// so UI consumers receive activity in a single fetch (no separate
// /api/rigs/:id/nodes round-trip just to color the topology dots).
const tmuxAdapter = c.get("tmuxAdapter" as never) as TmuxAdapter | undefined;
const agentActivityStore = c.get("agentActivityStore" as never) as AgentActivityStore | undefined;
const inventoryWithActivity = tmuxAdapter
? await attachAgentActivity(inventory, { tmuxAdapter, activityStore: agentActivityStore })
: inventory;
// PL-019 item 5: read-side join for active-qitem enrichment. Cheap by
// virtue of the existing idx_queue_items_destination_state index. The
// helper is exported so it can be unit-tested without spinning up the
// full route stack.
const currentQitemsBySession = loadCurrentQitemsForSessions(
getRepo(c).db,
inventoryWithActivity
.map((n) => n.canonicalSessionName)
.filter((s): s is string => Boolean(s))
);
const pods = getRepo(c).db
.prepare("SELECT id, rig_id, namespace, label, summary, continuity_policy_json, created_at FROM pods WHERE rig_id = ? ORDER BY created_at")
.all(rigId) as Array<{ id: string; rig_id: string; namespace: string; label: string; summary: string | null; continuity_policy_json: string | null; created_at: string }>;
const overlay: InventoryOverlay[] = inventory.map((n) => ({
const overlay: InventoryOverlay[] = inventoryWithActivity.map((n) => ({
logicalId: n.logicalId,
startupStatus: n.startupStatus,
canonicalSessionName: n.canonicalSessionName,
@@ -168,6 +229,10 @@ rigsRoutes.get("/:id/graph", (c) => {
contextUsedPercentage: n.contextUsage?.usedPercentage ?? null,
contextFresh: n.contextUsage?.fresh ?? false,
contextAvailability: n.contextUsage?.availability ?? "unknown",
agentActivity: n.agentActivity ?? null,
currentQitems: n.canonicalSessionName
? currentQitemsBySession.get(n.canonicalSessionName) ?? []
: [],
}));
const projectedPods: Pod[] = pods.map((pod) => ({
id: pod.id,
+19
View File
@@ -80,6 +80,25 @@ nodesRoutes.get("/:logicalId", async (c) => {
const [detailWithActivity] = await attachAgentActivity([detail], { tmuxAdapter: deps.tmuxAdapter, activityStore: deps.agentActivityStore });
Object.assign(detail, { agentActivity: detailWithActivity?.agentActivity });
// PL-019 item 5: surface in-progress qitems on node-detail when the
// node has a session name (matches /graph payload's enrichment shape).
if (detail.canonicalSessionName) {
const rows = deps.rigRepo.db.prepare(
`SELECT qitem_id, body, tier
FROM queue_items
WHERE state = 'in-progress' AND destination_session = ?
ORDER BY ts_updated DESC
LIMIT 3`
).all(detail.canonicalSessionName) as Array<{ qitem_id: string; body: string; tier: string | null }>;
Object.assign(detail, {
currentQitems: rows.map((r) => ({
qitemId: r.qitem_id,
bodyExcerpt: r.body.length > 80 ? `${r.body.slice(0, 80)}…` : r.body,
tier: r.tier,
})),
});
}
// Enrich transcript info from TranscriptStore (not available to pure DB helper)
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
if (transcriptStore?.enabled && detail.canonicalSessionName) {
@@ -0,0 +1,181 @@
// PL-019 items 4 + 5: graph projection carries agentActivity + currentQitems
// through InventoryOverlay so UI consumers (RigGraph, Explorer) see activity
// state and in-progress qitem ownership in a single payload.
import { describe, it, expect } from "vitest";
import { projectRigToGraph } from "../src/domain/graph-projection.js";
import type { InventoryOverlay, RigGraphInput } from "../src/domain/graph-projection.js";
import type { Pod, RigWithRelations, Session, AgentActivity } from "../src/domain/types.js";
function makeRig(
nodes: { id: string; logicalId: string; role?: string; runtime?: string }[],
edges: { id: string; sourceId: string; targetId: string; kind: string }[] = [],
sessions: Session[] = [],
pods: Pod[] = []
): RigGraphInput {
const rig: RigWithRelations = {
rig: { id: "rig-1", name: "r01", createdAt: "2026-05-04", updatedAt: "2026-05-04" },
nodes: nodes.map((n) => ({
id: n.id,
rigId: "rig-1",
logicalId: n.logicalId,
role: n.role ?? null,
runtime: n.runtime ?? null,
model: null,
cwd: null,
createdAt: "2026-05-04",
binding: null,
})),
edges: edges.map((e) => ({
id: e.id,
rigId: "rig-1",
sourceId: e.sourceId,
targetId: e.targetId,
kind: e.kind,
createdAt: "2026-05-04",
})),
};
return { ...rig, sessions, pods };
}
function makeRunningSession(nodeId: string): Session {
return {
id: `sess-${nodeId}-2026-05-04`,
nodeId,
sessionName: `r01-${nodeId}`,
status: "running",
lastSeenAt: null,
createdAt: "2026-05-04",
};
}
const RUNNING_ACTIVITY: AgentActivity = {
state: "running",
reason: "mid_work_pattern",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "implementing PL-019",
};
describe("PL-019 projectRigToGraph: agentActivity + currentQitems", () => {
it("projects agentActivity onto the RFNodeData when overlay carries it", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
);
const overlay: InventoryOverlay[] = [
{
logicalId: "alpha",
startupStatus: "ready",
canonicalSessionName: "r01-alpha",
restoreOutcome: "n-a",
agentActivity: RUNNING_ACTIVITY,
},
];
const result = projectRigToGraph(input, overlay);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].data.agentActivity).toEqual(RUNNING_ACTIVITY);
});
it("defaults agentActivity to null when overlay omits it", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
);
const overlay: InventoryOverlay[] = [
{ logicalId: "alpha", startupStatus: "ready", canonicalSessionName: "r01-alpha", restoreOutcome: "n-a" },
];
const result = projectRigToGraph(input, overlay);
expect(result.nodes[0].data.agentActivity).toBeNull();
});
it("projects currentQitems onto running nodes when overlay carries them", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
);
const overlay: InventoryOverlay[] = [
{
logicalId: "alpha",
startupStatus: "ready",
canonicalSessionName: "r01-alpha",
restoreOutcome: "n-a",
agentActivity: RUNNING_ACTIVITY,
currentQitems: [
{ qitemId: "qitem-20260504001234-aaaa1111", bodyExcerpt: "Audit row 1", tier: "mode2" },
{ qitemId: "qitem-20260504001235-bbbb2222", bodyExcerpt: "Phase A R1", tier: null },
],
},
];
const result = projectRigToGraph(input, overlay);
const data = result.nodes[0].data;
expect(data.currentQitems).toHaveLength(2);
expect(data.currentQitems?.[0].qitemId).toBe("qitem-20260504001234-aaaa1111");
expect(data.currentQitems?.[0].tier).toBe("mode2");
});
it("defaults currentQitems to [] when overlay omits them", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
);
const overlay: InventoryOverlay[] = [
{ logicalId: "alpha", startupStatus: "ready", canonicalSessionName: "r01-alpha", restoreOutcome: "n-a" },
];
const result = projectRigToGraph(input, overlay);
expect(result.nodes[0].data.currentQitems).toEqual([]);
});
it("pod group nodes carry null/empty defaults (no inventory mapping)", () => {
const pod: Pod = {
id: "pod-1",
rigId: "rig-1",
namespace: "demo-ns",
label: "demo",
summary: null,
continuityPolicyJson: null,
createdAt: "2026-05-04",
};
const input = {
...makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
),
// Attach pod by mutating the synthesized node:
};
input.nodes[0].podId = pod.id;
input.pods = [pod];
const result = projectRigToGraph(input);
const podNode = result.nodes.find((n) => n.id.startsWith("pod-"));
expect(podNode).toBeDefined();
expect(podNode!.data.agentActivity).toBeNull();
expect(podNode!.data.currentQitems).toEqual([]);
});
it("multiple nodes with mixed activity states project independently", () => {
const input = makeRig(
[
{ id: "n1", logicalId: "alpha" },
{ id: "n2", logicalId: "beta" },
{ id: "n3", logicalId: "gamma" },
],
[],
[makeRunningSession("n1"), makeRunningSession("n2"), makeRunningSession("n3")]
);
const overlay: InventoryOverlay[] = [
{ logicalId: "alpha", startupStatus: "ready", canonicalSessionName: "r01-alpha", restoreOutcome: "n-a", agentActivity: RUNNING_ACTIVITY },
{ logicalId: "beta", startupStatus: "ready", canonicalSessionName: "r01-beta", restoreOutcome: "n-a", agentActivity: { ...RUNNING_ACTIVITY, state: "needs_input", reason: "approval_pending" } },
{ logicalId: "gamma", startupStatus: "ready", canonicalSessionName: "r01-gamma", restoreOutcome: "n-a", agentActivity: { ...RUNNING_ACTIVITY, state: "idle", reason: "idle_prompt" } },
];
const result = projectRigToGraph(input, overlay);
const states = result.nodes
.filter((n) => n.type === "rigNode")
.map((n) => n.data.agentActivity?.state);
expect(states).toEqual(["running", "needs_input", "idle"]);
});
});
+8 -1
View File
@@ -24,6 +24,13 @@ import { externalCliAttachmentSchema } from "../../src/db/migrations/019_externa
import { rigServicesSchema } from "../../src/db/migrations/020_rig_services.js";
import { seatHandoverObservabilitySchema } from "../../src/db/migrations/021_seat_handover_observability.js";
import { nodeCodexConfigProfileSchema } from "../../src/db/migrations/022_node_codex_config_profile.js";
// PL-019: GET /api/rigs/:id/graph + GET /api/rigs/:rigId/nodes/:logicalId
// now perform a read-side join over queue_items (in-progress qitem
// ownership per session). The route returns 500 if the table is absent,
// so the integration harness needs the queue migrations through 025.
import { streamItemsSchema } from "../../src/db/migrations/023_stream_items.js";
import { queueItemsSchema } from "../../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../../src/db/migrations/025_queue_transitions.js";
import { BootstrapRepository } from "../../src/domain/bootstrap-repository.js";
import { RuntimeVerifier } from "../../src/domain/runtime-verifier.js";
import { RequirementsProbeRegistry } from "../../src/domain/requirements-probe.js";
@@ -78,7 +85,7 @@ import fs from "node:fs";
export function createFullTestDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema]);
return db;
}
@@ -0,0 +1,141 @@
// PL-019 item 5: read-side join helper used by GET /api/rigs/:id/graph
// to attach in-progress qitem ownership per node session. Tested as a
// focused unit so we exercise the SQL shape + capping + body-excerpt
// behavior without spinning up the full route stack (which depends on
// many migrations / services).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { loadCurrentQitemsForSessions } from "../src/routes/rigs.js";
function insertQitem(db: Database.Database, opts: {
qitemId: string;
source: string;
destination: string;
state: string;
body: string;
tier?: string | null;
tsUpdated?: string;
}): void {
db.prepare(
`INSERT INTO queue_items (
qitem_id, ts_created, ts_updated, source_session, destination_session,
state, priority, body, tier
) VALUES (?, ?, ?, ?, ?, ?, 'routine', ?, ?)`
).run(
opts.qitemId,
"2026-05-04T00:00:00.000Z",
opts.tsUpdated ?? "2026-05-04T00:00:00.000Z",
opts.source,
opts.destination,
opts.state,
opts.body,
opts.tier ?? null,
);
}
describe("PL-019 loadCurrentQitemsForSessions read-side join", () => {
let db: Database.Database;
beforeEach(() => {
db = createDb();
migrate(db, [
coreSchema, eventsSchema, streamItemsSchema,
queueItemsSchema, queueTransitionsSchema,
]);
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
});
afterEach(() => db.close());
it("returns empty map for empty session list", () => {
const result = loadCurrentQitemsForSessions(db, []);
expect(result.size).toBe(0);
});
it("returns empty map when no qitems exist for the queried sessions", () => {
insertQitem(db, { qitemId: "q1", source: "src@r", destination: "other@r", state: "in-progress", body: "x" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r", "beta@r"]);
expect(result.size).toBe(0);
});
it("includes only state='in-progress' qitems (excludes pending/closed/handed-off)", () => {
insertQitem(db, { qitemId: "q-pending", source: "s@r", destination: "alpha@r", state: "pending", body: "p" });
insertQitem(db, { qitemId: "q-inprog", source: "s@r", destination: "alpha@r", state: "in-progress", body: "wip" });
insertQitem(db, { qitemId: "q-closed", source: "s@r", destination: "alpha@r", state: "closed", body: "done" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
expect(result.get("alpha@r")).toHaveLength(1);
expect(result.get("alpha@r")?.[0].qitemId).toBe("q-inprog");
});
it("groups qitems by destination_session", () => {
insertQitem(db, { qitemId: "q1", source: "s@r", destination: "alpha@r", state: "in-progress", body: "a" });
insertQitem(db, { qitemId: "q2", source: "s@r", destination: "beta@r", state: "in-progress", body: "b" });
insertQitem(db, { qitemId: "q3", source: "s@r", destination: "alpha@r", state: "in-progress", body: "c" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r", "beta@r"]);
expect(result.get("alpha@r")).toHaveLength(2);
expect(result.get("beta@r")).toHaveLength(1);
});
it("caps per-node at 3 qitems even when more in-progress rows exist", () => {
for (let i = 0; i < 6; i++) {
insertQitem(db, {
qitemId: `q-${i}`,
source: "s@r",
destination: "alpha@r",
state: "in-progress",
body: `row ${i}`,
tsUpdated: `2026-05-04T00:00:0${i}.000Z`,
});
}
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
expect(result.get("alpha@r")).toHaveLength(3);
});
it("excerpts body bodies longer than 80 chars with ellipsis", () => {
const longBody = "x".repeat(120);
insertQitem(db, { qitemId: "q-long", source: "s@r", destination: "alpha@r", state: "in-progress", body: longBody });
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
const entry = result.get("alpha@r")?.[0];
expect(entry).toBeDefined();
expect(entry!.bodyExcerpt.length).toBeLessThanOrEqual(81); // 80 + ellipsis
expect(entry!.bodyExcerpt.endsWith("…")).toBe(true);
});
it("preserves short bodies verbatim", () => {
insertQitem(db, { qitemId: "q-short", source: "s@r", destination: "alpha@r", state: "in-progress", body: "tiny" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
expect(result.get("alpha@r")?.[0].bodyExcerpt).toBe("tiny");
});
it("orders rows by ts_updated DESC (most-recently-touched first)", () => {
insertQitem(db, { qitemId: "q-old", source: "s@r", destination: "alpha@r", state: "in-progress", body: "old", tsUpdated: "2026-05-04T00:00:00.000Z" });
insertQitem(db, { qitemId: "q-mid", source: "s@r", destination: "alpha@r", state: "in-progress", body: "mid", tsUpdated: "2026-05-04T01:00:00.000Z" });
insertQitem(db, { qitemId: "q-new", source: "s@r", destination: "alpha@r", state: "in-progress", body: "new", tsUpdated: "2026-05-04T02:00:00.000Z" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
const list = result.get("alpha@r") ?? [];
expect(list.map((q) => q.qitemId)).toEqual(["q-new", "q-mid", "q-old"]);
});
it("carries tier through verbatim (string or null)", () => {
insertQitem(db, { qitemId: "q-mode2", source: "s@r", destination: "alpha@r", state: "in-progress", body: "x", tier: "mode2" });
insertQitem(db, { qitemId: "q-no-tier", source: "s@r", destination: "alpha@r", state: "in-progress", body: "y", tier: null, tsUpdated: "2026-05-04T01:00:00.000Z" });
const result = loadCurrentQitemsForSessions(db, ["alpha@r"]);
const list = result.get("alpha@r") ?? [];
const tierMode2 = list.find((q) => q.qitemId === "q-mode2");
const tierNull = list.find((q) => q.qitemId === "q-no-tier");
expect(tierMode2?.tier).toBe("mode2");
expect(tierNull?.tier).toBeNull();
});
});
+58 -1
View File
@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useRouterState } from "@tanstack/react-router";
import { Boxes, ChevronLeft, ChevronRight, CircleDot, Globe, Layers3, Server } from "lucide-react";
import { Boxes, ChevronLeft, ChevronRight, CircleDot, Globe, Layers3, Server, Activity } from "lucide-react";
import { useRigSummary, type RigSummary } from "../hooks/useRigSummary.js";
import { usePsEntries, type PsEntry } from "../hooks/usePsEntries.js";
import { useNodeInventory, type NodeInventoryEntry } from "../hooks/useNodeInventory.js";
import { cn } from "../lib/utils.js";
import { displayAgentName, displayPodName, inferPodName } from "../lib/display-name.js";
import {
getActivityState,
getActivityLabel,
getActivityTextClass,
getActivityAnimationClass,
shortQitemTail,
} from "../lib/activity-visuals.js";
import type { DrawerSelection } from "./SharedDetailDrawer.js";
@@ -76,6 +83,54 @@ function TreeToggle({
);
}
// PL-019 item 3: per-row activity indicator that sits next to the
// startup-status icon. Uses the same shared palette as RigNode (item 2)
// so the operator's mental model is the same on both surfaces.
//
// "Owns active work" tag (qitem tooltip) renders only when the daemon
// attached one or more in-progress qitems on the node-detail/inventory
// payload — currentQitems comes from the read-side join in routes/sessions.ts
// + routes/rigs.ts.
function NodeActivityIndicator({ node }: { node: NodeInventoryEntry }) {
const activity = node.agentActivity;
const state = getActivityState(activity);
const label = getActivityLabel(state);
const textClass = getActivityTextClass(state);
const animClass = getActivityAnimationClass(state);
const qitems = node.currentQitems ?? [];
// Build a single tooltip line summarizing activity + (if running) the
// owned qitem(s). Operator-friendly: the explorer is a tree, the drawer
// shows the full id; the tooltip is the short answer.
const titleLines = [`activity: ${label}`];
if (qitems.length > 0) {
for (const q of qitems) {
titleLines.push(`on ${shortQitemTail(q.qitemId)} — ${q.bodyExcerpt}`);
}
}
const title = titleLines.join("\n");
return (
<span
className="inline-flex items-center gap-0.5 ml-1"
data-testid={`node-activity-${node.logicalId}`}
data-activity-state={state}
title={title}
>
<Activity className={cn("h-2.5 w-2.5 shrink-0", textClass, animClass)} strokeWidth={2.4} aria-label={title} />
{qitems.length > 0 && (
<span
className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500"
data-testid={`node-active-work-${node.logicalId}`}
aria-label="owns active work"
>
●
</span>
)}
</span>
);
}
function ExplorerKindIcon({
kind,
statusClass,
@@ -164,6 +219,7 @@ function PodBranch({
statusClass={statusColor(node.startupStatus)}
testId={`node-icon-${node.logicalId}`}
/>
<NodeActivityIndicator node={node} />
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
{node.contextUsage?.availability === "known" && typeof node.contextUsage.usedPercentage === "number" ? (
<span
@@ -288,6 +344,7 @@ function RigBranch({
statusClass={statusColor(node.startupStatus)}
testId={`node-icon-${node.logicalId}`}
/>
<NodeActivityIndicator node={node} />
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
</button>
);
+44 -3
View File
@@ -3,6 +3,7 @@ import { ReactFlow, Controls, Handle, Position, type NodeTypes, type Node, type
import "@xyflow/react/dist/style.css";
import { useRigGraph } from "../hooks/useRigGraph.js";
import { useRigEvents } from "../hooks/useRigEvents.js";
import { useTopologyEdgeActivity } from "../hooks/useTopologyEdgeActivity.js";
import { useDiscoveredSessionsConditional, type DiscoveredSession } from "../hooks/useDiscovery.js";
import { useDiscoveryPlacement, useDrawerSelection, useNodeSelection } from "./AppShell.js";
import { getEdgeStyle } from "@/lib/edge-styles";
@@ -105,6 +106,7 @@ export function RigGraph({
const rawEdges = data?.edges ?? [];
const error = queryError?.message ?? null;
const { reconnecting } = useRigEvents(rigId);
const edgeActivity = useTopologyEdgeActivity();
const [focusMessage, setFocusMessage] = useState<FocusMessage | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -134,23 +136,62 @@ export function RigGraph({
};
}, []);
// Apply edge styles from design system + entrance animation
// PL-019 item 6: build a node-id → canonicalSessionName lookup so edge
// activity (keyed on session names from /api/events) can be resolved
// against React Flow edges (keyed on internal node ids). Built each
// render off rawNodes; cheap (O(n) on graph nodes), and node-id is
// stable for a given rig, so edges stay stable visually too.
const sessionNameByNodeId = useMemo(() => {
const map = new Map<string, string>();
for (const node of rawNodes as Node[]) {
const data = node.data as { canonicalSessionName?: string | null } | undefined;
if (data?.canonicalSessionName) {
map.set(node.id, data.canonicalSessionName);
}
}
return map;
}, [rawNodes]);
// Apply edge styles from design system + entrance animation +
// PL-019 traffic emphasis. Class composition order:
// - shouldAnimate: one-shot entrance (existing behavior; not toggled
// after first render).
// - edge-recent-traffic: sustained 1.5x weight + brighter for any
// edge whose directed pair fired in the last ~30s.
// - edge-just-fired: one-shot 1.2s pulse on top of the sustained
// emphasis when the most recent firing is within ~1.2s.
// edgeActivity.version is included in deps so the memo refreshes when
// a new event arrives or an old one ages out of the recent window.
const rfEdges = useMemo(() => {
return (rawEdges as (Edge & { data?: { kind?: string } })[]).map((edge) => {
const kind = (edge as { data?: { kind?: string } }).data?.kind ??
(edge as { label?: string }).label ?? "delegates_to";
const styleResult = getEdgeStyle(kind);
const sourceSession = sessionNameByNodeId.get(edge.source as string);
const destSession = sessionNameByNodeId.get(edge.target as string);
const recentTraffic = sourceSession && destSession
? edgeActivity.recentTraffic(sourceSession, destSession)
: false;
const justFired = sourceSession && destSession
? edgeActivity.justFired(sourceSession, destSession)
: false;
const trafficClass = [
shouldAnimate ? "edge-draw-in" : "",
recentTraffic ? "edge-recent-traffic" : "",
justFired ? "edge-just-fired" : "",
].filter(Boolean).join(" ") || undefined;
return {
...edge,
...styleResult,
className: shouldAnimate ? "edge-draw-in" : undefined,
className: trafficClass,
style: {
...styleResult.style,
animationDelay: shouldAnimate ? `${Math.min(rawNodes.length * 50 + 100, 2000)}ms` : undefined,
},
};
});
}, [rawEdges, shouldAnimate, rawNodes.length]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawEdges, shouldAnimate, rawNodes.length, sessionNameByNodeId, edgeActivity.version]);
// Apply tree layout + entrance animation to nodes
const podMetaById = useMemo(() => {
+54 -28
View File
@@ -2,6 +2,15 @@ import { useRef, useEffect, useState } from "react";
import { Handle, Position } from "@xyflow/react";
import { copyText } from "../lib/copy-text.js";
import { displayAgentName } from "../lib/display-name.js";
import {
getActivityState,
getActivityLabel,
getActivityBgClass,
getActivityAnimationClass,
isActivityStale,
shortQitemTail,
} from "../lib/activity-visuals.js";
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
interface RigNodeData {
logicalId: string;
@@ -28,6 +37,11 @@ interface RigNodeData {
contextFresh?: boolean;
contextAvailability?: string;
placementState?: "available" | "selected" | null;
// PL-019: agent activity drives the node's primary "is this agent
// working?" dot color (replacing the previous startup-status color).
// currentQitems surfaces in the hover hint when the agent is running.
agentActivity?: AgentActivitySummary | null;
currentQitems?: CurrentQitemSummary[];
}
/** Core roles get dark header stripe, workers get light */
@@ -35,26 +49,6 @@ function isCore(role: string | null): boolean {
return role === "architect" || role === "lead" || role === "orchestrator";
}
function getStartupStatusLabel(startupStatus: string | null | undefined): string {
switch (startupStatus) {
case "ready": return "ready";
case "pending": return "launching";
case "attention_required": return "attention";
case "failed": return "failed";
default: return "stopped";
}
}
function getStartupStatusColorClass(startupStatus: string | null | undefined): string {
switch (startupStatus) {
case "ready": return "bg-green-500";
case "pending": return "bg-amber-500";
case "attention_required": return "bg-orange-500";
case "failed": return "bg-red-500";
default: return "bg-stone-400";
}
}
export function RigNode({ data }: { data: RigNodeData }) {
const prevStatusRef = useRef(data.startupStatus);
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -83,15 +77,35 @@ export function RigNode({ data }: { data: RigNodeData }) {
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" \u00B7 ");
const agentName = displayAgentName(data.logicalId);
const statusLabel = getStartupStatusLabel(data.startupStatus);
const statusClass = getStartupStatusColorClass(data.startupStatus);
// PL-019: dot color is now driven by agentActivity.state, not startupStatus.
// startupStatus retains its independent surface via the ATTN/FAILED badges
// below \u2014 activity answers "is this agent working?", startup answers "did
// this agent boot?". Two different questions, two different surfaces.
const activityState = getActivityState(data.agentActivity);
const activityLabel = getActivityLabel(activityState);
const activityBgClass = getActivityBgClass(activityState);
const activityAnimClass = getActivityAnimationClass(activityState);
const activityIsStale = isActivityStale(data.agentActivity);
const placementChipLabel = data.placementState === "selected" ? "target" : data.placementState === "available" ? "avail" : null;
// PL-019 item 5: include active-qitem summary in the hover hint when the
// agent is currently running and the daemon attached one or more qitems.
// Short ULID tail for hover; full id is in the drawer (separate surface).
const currentQitems = data.currentQitems ?? [];
const qitemHoverLines = currentQitems.length > 0
? currentQitems.map((q) => `On: ${shortQitemTail(q.qitemId)} \u2014 ${q.bodyExcerpt}`)
: [];
const hoverHintLines = [
`Activity: ${activityLabel}${activityIsStale ? " (stale sample)" : ""}`,
data.canonicalSessionName ? `Session: ${data.canonicalSessionName}` : null,
runtimeModel ? `Runtime: ${runtimeModel}` : null,
data.resolvedSpecName ? `Spec: ${data.resolvedSpecName}` : null,
data.profile ? `Profile: ${data.profile}` : null,
typeof data.edgeCount === "number" ? `Edges: ${data.edgeCount}` : null,
...qitemHoverLines,
].filter((line): line is string => Boolean(line));
const hoverHint = hoverHintLines.join("\n");
@@ -170,12 +184,24 @@ export function RigNode({ data }: { data: RigNodeData }) {
<span className="font-bold truncate">
{agentName}
</span>
<span
className={`inline-flex h-2.5 w-2.5 rounded-full border border-white/50 ${statusClass} ${statusChanged ? "status-changed" : ""}`}
data-testid={`status-dot-${data.logicalId}`}
aria-label={statusLabel}
title={statusLabel}
/>
<span className="inline-flex items-center gap-1">
{activityIsStale && (
<span
data-testid={`activity-staleness-${data.logicalId}`}
className="font-mono text-[7px] uppercase tracking-[0.10em] text-stone-300"
title={`Activity sample is older than threshold; daemon may not be probing this seat`}
>
stale
</span>
)}
<span
className={`inline-flex h-2.5 w-2.5 rounded-full border border-white/50 ${activityBgClass} ${activityAnimClass} ${statusChanged ? "status-changed" : ""}`}
data-testid={`activity-dot-${data.logicalId}`}
data-activity-state={activityState}
aria-label={`activity: ${activityLabel}`}
title={`activity: ${activityLabel}`}
/>
</span>
</div>
{/* Body */}
+40 -1
View File
@@ -250,6 +250,43 @@
}
}
/* === PL-019 ACTIVITY INDICATORS === */
@layer components {
/* Subtle slow pulse for the running activity state on RigNode dot.
Low-amplitude opacity + box-shadow halo. The brief is explicit:
pulses, not blinking — running stays calm and confident, needs_input
is the eye-catcher (and stays static color). */
@keyframes activity-pulse-running-frames {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.0); }
50% { opacity: 0.85; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18); }
}
.activity-pulse-running {
animation: activity-pulse-running-frames 1.8s ease-in-out infinite;
}
/* PL-019 item 6 — edge animation. One-shot ~1.2s emphasis on edges
between source-seat and dest-seat when a queue event fires. React Flow
renders edges as SVG <path> wrapped in .react-flow__edge; we toggle
classes on the wrapper through the edge `className` prop so the SVG
stroke responds without us swapping React Flow's edge component. */
.edge-recent-traffic .react-flow__edge-path,
.react-flow__edge.edge-recent-traffic .react-flow__edge-path {
stroke-width: 3px !important;
filter: brightness(1.15);
transition: stroke-width 250ms ease-out, filter 250ms ease-out;
}
.edge-just-fired .react-flow__edge-path,
.react-flow__edge.edge-just-fired .react-flow__edge-path {
animation: edge-just-fired-frames 1.2s ease-out 1;
}
@keyframes edge-just-fired-frames {
0% { stroke-width: 3px; filter: brightness(1.6); }
60% { stroke-width: 3px; filter: brightness(1.3); }
100% { stroke-width: 3px; filter: brightness(1.15); }
}
}
/* === REDUCED MOTION === */
@media (prefers-reduced-motion: reduce) {
@@ -258,7 +295,9 @@
.node-enter,
.edge-draw-in,
.shimmer,
.animate-pulse-tactical {
.animate-pulse-tactical,
.activity-pulse-running,
.edge-just-fired {
animation: none;
opacity: 1;
transform: none;
+19
View File
@@ -1,5 +1,20 @@
import { useQuery } from "@tanstack/react-query";
export interface AgentActivitySummary {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
evidenceSource: string;
sampledAt: string;
evidence?: string | null;
staleness?: number | null;
}
export interface CurrentQitemSummary {
qitemId: string;
bodyExcerpt: string;
tier: string | null;
}
export interface NodeInventoryEntry {
rigId: string;
rigName: string;
@@ -23,6 +38,10 @@ export interface NodeInventoryEntry {
sampledAt: string | null;
fresh: boolean;
};
// PL-019: agent activity attached daemon-side via attachAgentActivity.
agentActivity?: AgentActivitySummary | null;
// PL-019: in-progress qitems joined daemon-side on node-detail responses.
currentQitems?: CurrentQitemSummary[];
}
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
// PL-019: shared activity-state visual mapping. Used by both RigNode (graph
// dot) and Explorer (tree-row icon) so the operator sees one consistent
// palette across surfaces. Design guidance from orch (2026-05-04):
//
// running warm green/teal static + very subtle slow pulse
// needs_input amber, the one state that catches the eye
// idle calm cool static (blue-gray), no motion
// unknown desaturated gray, no motion
//
// startupStatus (failed / attention_required) is a separate signal and stays
// surfaced via the existing ATTN/FAILED badges on RigNode — activity color
// answers "is this agent working?", startup color answers "did this agent
// boot?". They are not the same question.
import type { AgentActivitySummary } from "../hooks/useNodeInventory.js";
export type ActivityState = "running" | "needs_input" | "idle" | "unknown";
const ACTIVITY_LABELS: Record<ActivityState, string> = {
running: "running",
needs_input: "needs input",
idle: "idle",
unknown: "unknown",
};
// Tailwind utility classes — chosen to fit the existing stone/emerald
// palette already in use across RigNode and Explorer rather than adding a
// new brand. running uses emerald-500 (warm green); needs_input uses
// amber-500 (the eye-catcher); idle uses slate-400 (cool blue-gray); unknown
// uses stone-300 (desaturated, ignorable).
const ACTIVITY_BG_CLASSES: Record<ActivityState, string> = {
running: "bg-emerald-500",
needs_input: "bg-amber-500",
idle: "bg-slate-400",
unknown: "bg-stone-300",
};
const ACTIVITY_TEXT_CLASSES: Record<ActivityState, string> = {
running: "text-emerald-600",
needs_input: "text-amber-600",
idle: "text-slate-500",
unknown: "text-stone-400",
};
export function getActivityState(activity: AgentActivitySummary | null | undefined): ActivityState {
if (!activity) return "unknown";
return activity.state;
}
export function getActivityLabel(state: ActivityState): string {
return ACTIVITY_LABELS[state];
}
export function getActivityBgClass(state: ActivityState): string {
return ACTIVITY_BG_CLASSES[state];
}
export function getActivityTextClass(state: ActivityState): string {
return ACTIVITY_TEXT_CLASSES[state];
}
// Subtle slow pulse for running (~2s cycle, low-amplitude opacity). Only
// running animates; needs_input gets a static stronger color (the brief is
// explicit that needs_input is the one that catches the eye, but we keep it
// non-flashing so it doesn't compete with itself).
export function getActivityAnimationClass(state: ActivityState): string {
if (state === "running") return "activity-pulse-running";
return "";
}
// Staleness badge threshold: anything beyond ~30s of staleness gets a small
// muted indicator, since stale activity samples can mislead. Driver picks
// the threshold; PL-019 plans for ~30s as the operator-perceptible boundary.
const STALENESS_THRESHOLD_SECONDS = 30;
export function isActivityStale(activity: AgentActivitySummary | null | undefined): boolean {
if (!activity) return false;
// staleness may not be wired by every probe path; fall back to delta from
// sampledAt if it is missing.
if (typeof activity.staleness === "number") {
return activity.staleness > STALENESS_THRESHOLD_SECONDS;
}
if (!activity.sampledAt) return false;
const sampled = Date.parse(activity.sampledAt);
if (Number.isNaN(sampled)) return false;
const ageSeconds = (Date.now() - sampled) / 1000;
return ageSeconds > STALENESS_THRESHOLD_SECONDS;
}
// Short ULID tail for hover hints. Full id stays available in the drawer.
export function shortQitemTail(qitemId: string): string {
if (qitemId.length <= 8) return qitemId;
return qitemId.slice(-8);
}
@@ -0,0 +1,115 @@
// PL-019: shared activity visual mapping. Pure logic — no React, no DOM.
// Validates the four-state palette + the staleness threshold + the
// running-only animation rule (per orch design guidance: pulses not
// blinking; needs_input is the static eye-catcher; idle/unknown are
// motion-free).
import { describe, it, expect } from "vitest";
import {
getActivityState,
getActivityLabel,
getActivityBgClass,
getActivityTextClass,
getActivityAnimationClass,
isActivityStale,
shortQitemTail,
type ActivityState,
} from "../src/lib/activity-visuals.js";
const STATES: ActivityState[] = ["running", "needs_input", "idle", "unknown"];
describe("PL-019 activity-visuals", () => {
describe("getActivityState", () => {
it("returns 'unknown' when activity is null/undefined", () => {
expect(getActivityState(null)).toBe("unknown");
expect(getActivityState(undefined)).toBe("unknown");
});
it("returns the state from the activity payload", () => {
for (const state of STATES) {
expect(getActivityState({ state, reason: "x", evidenceSource: "y", sampledAt: "z" })).toBe(state);
}
});
});
describe("getActivityLabel / getActivityBgClass / getActivityTextClass", () => {
it("provides a non-empty label for every state", () => {
for (const state of STATES) {
expect(getActivityLabel(state).length).toBeGreaterThan(0);
}
});
it("running uses warm green/teal (emerald-500)", () => {
expect(getActivityBgClass("running")).toBe("bg-emerald-500");
});
it("needs_input uses the eye-catcher amber-500 (the one state designed to grab attention)", () => {
expect(getActivityBgClass("needs_input")).toBe("bg-amber-500");
});
it("idle uses calm cool slate (no warm attention-grabbing)", () => {
expect(getActivityBgClass("idle")).toBe("bg-slate-400");
});
it("unknown uses desaturated stone (ignorable)", () => {
expect(getActivityBgClass("unknown")).toBe("bg-stone-300");
});
it("text classes mirror bg classes with -600/-500/-400 contrast", () => {
expect(getActivityTextClass("running")).toBe("text-emerald-600");
expect(getActivityTextClass("needs_input")).toBe("text-amber-600");
expect(getActivityTextClass("idle")).toBe("text-slate-500");
expect(getActivityTextClass("unknown")).toBe("text-stone-400");
});
});
describe("getActivityAnimationClass", () => {
it("running gets the slow-pulse class (subtle, not flashy)", () => {
expect(getActivityAnimationClass("running")).toBe("activity-pulse-running");
});
it("needs_input does NOT animate — it grabs attention via static color (per orch design)", () => {
expect(getActivityAnimationClass("needs_input")).toBe("");
});
it("idle and unknown never animate (no motion)", () => {
expect(getActivityAnimationClass("idle")).toBe("");
expect(getActivityAnimationClass("unknown")).toBe("");
});
});
describe("isActivityStale", () => {
it("returns false when activity is null/undefined", () => {
expect(isActivityStale(null)).toBe(false);
expect(isActivityStale(undefined)).toBe(false);
});
it("returns true when explicit staleness exceeds threshold (~30s)", () => {
expect(isActivityStale({ state: "idle", reason: "x", evidenceSource: "y", sampledAt: "2026-05-04T00:00:00.000Z", staleness: 60 })).toBe(true);
});
it("returns false when explicit staleness is within threshold", () => {
expect(isActivityStale({ state: "idle", reason: "x", evidenceSource: "y", sampledAt: "2026-05-04T00:00:00.000Z", staleness: 5 })).toBe(false);
});
it("falls back to age-from-sampledAt when staleness is missing", () => {
const longAgo = new Date(Date.now() - 120_000).toISOString();
expect(isActivityStale({ state: "idle", reason: "x", evidenceSource: "y", sampledAt: longAgo })).toBe(true);
});
it("returns false for fresh sampledAt without explicit staleness", () => {
const fresh = new Date(Date.now() - 1_000).toISOString();
expect(isActivityStale({ state: "running", reason: "x", evidenceSource: "y", sampledAt: fresh })).toBe(false);
});
});
describe("shortQitemTail", () => {
it("returns the last 8 chars for normal ULID-tail qitem ids", () => {
expect(shortQitemTail("qitem-20260504001234-abcd1234")).toBe("abcd1234");
});
it("returns the full id verbatim when shorter than 8 chars", () => {
expect(shortQitemTail("q-1")).toBe("q-1");
});
});
});
+112 -19
View File
@@ -544,7 +544,9 @@ describe("RigNode", () => {
expect(screen.getByText("impl")).toBeDefined();
expect(screen.getByText("RUNTIME: claude-code · opus")).toBeDefined();
expect(screen.queryByText("WORKER")).toBeNull();
expect(screen.getByTestId("status-dot-dev.impl").getAttribute("aria-label")).toBe("stopped");
// PL-019: dot now reflects agentActivity (was startupStatus); no
// agentActivity attached → "unknown" state, desaturated stone.
expect(screen.getByTestId("activity-dot-dev.impl").getAttribute("aria-label")).toBe("activity: unknown");
});
it("shows a compact stopped indicator when binding is null and status is null", () => {
@@ -563,29 +565,39 @@ describe("RigNode", () => {
</ReactFlowProvider>
);
expect(screen.getByTestId("status-dot-worker").getAttribute("aria-label")).toBe("stopped");
// PL-019: no agentActivity attached → unknown.
expect(screen.getByTestId("activity-dot-worker").getAttribute("aria-label")).toBe("activity: unknown");
});
it("status dot uses color-only state on graph cards", () => {
// NS-T12: status colors now based on startupStatus
const statuses = [
{ status: "running", startupStatus: "ready", expectedLabel: "ready", expectedClass: "bg-green-500" },
{ status: "running", startupStatus: "pending", expectedLabel: "launching", expectedClass: "bg-amber-500" },
{ status: "running", startupStatus: "attention_required", expectedLabel: "attention", expectedClass: "bg-orange-500" },
{ status: "running", startupStatus: "failed", expectedLabel: "failed", expectedClass: "bg-red-500" },
{ status: null, startupStatus: null, expectedLabel: "stopped", expectedClass: "bg-stone-400" },
it("PL-019: activity dot color is driven by agentActivity.state (not startupStatus)", () => {
// The four states + the no-activity fallback cover the entire palette
// contract. Per orch design guidance: running uses warm green/teal,
// needs_input is the static eye-catcher (amber), idle is calm cool
// (slate-400), unknown is desaturated (stone-300).
const cases = [
{ state: "running" as const, expectedLabel: "activity: running", expectedClass: "bg-emerald-500" },
{ state: "needs_input" as const, expectedLabel: "activity: needs input", expectedClass: "bg-amber-500" },
{ state: "idle" as const, expectedLabel: "activity: idle", expectedClass: "bg-slate-400" },
{ state: "unknown" as const, expectedLabel: "activity: unknown", expectedClass: "bg-stone-300" },
];
for (const { status, startupStatus, expectedLabel, expectedClass } of statuses) {
for (const { state, expectedLabel, expectedClass } of cases) {
cleanup();
const data = {
logicalId: "test-node",
role: "worker",
runtime: "claude-code",
model: null,
status,
startupStatus,
status: "running",
startupStatus: "ready" as const,
binding: null,
agentActivity: {
state,
reason: "test",
evidenceSource: "pane_heuristic" as const,
sampledAt: new Date().toISOString(),
evidence: null,
},
};
render(
@@ -594,12 +606,89 @@ describe("RigNode", () => {
</ReactFlowProvider>
);
const dot = screen.getByTestId("status-dot-test-node");
const dot = screen.getByTestId("activity-dot-test-node");
expect(dot.getAttribute("aria-label")).toBe(expectedLabel);
expect(dot.getAttribute("data-activity-state")).toBe(state);
expect(dot.className).toContain(expectedClass);
}
});
it("PL-019: only running gets the subtle pulse animation; needs_input/idle/unknown stay static", () => {
const animatingStates = [{ state: "running" as const, shouldPulse: true }];
const nonAnimatingStates: { state: "needs_input" | "idle" | "unknown"; shouldPulse: boolean }[] = [
{ state: "needs_input", shouldPulse: false },
{ state: "idle", shouldPulse: false },
{ state: "unknown", shouldPulse: false },
];
for (const { state, shouldPulse } of [...animatingStates, ...nonAnimatingStates]) {
cleanup();
render(
<ReactFlowProvider>
<RigNode data={{
logicalId: "n",
role: "worker",
runtime: "claude-code",
model: null,
status: "running",
startupStatus: "ready" as const,
binding: null,
agentActivity: { state, reason: "x", evidenceSource: "pane_heuristic" as const, sampledAt: new Date().toISOString(), evidence: null },
}} />
</ReactFlowProvider>
);
const dot = screen.getByTestId("activity-dot-n");
if (shouldPulse) {
expect(dot.className).toContain("activity-pulse-running");
} else {
expect(dot.className).not.toContain("activity-pulse-running");
}
}
});
it("PL-019: stale activity (sampledAt > threshold) renders the small staleness badge next to the dot", () => {
const longAgo = new Date(Date.now() - 120_000).toISOString();
render(
<ReactFlowProvider>
<RigNode data={{
logicalId: "stale-node",
role: "worker",
runtime: "claude-code",
model: null,
status: "running",
startupStatus: "ready" as const,
binding: null,
agentActivity: { state: "running", reason: "x", evidenceSource: "pane_heuristic" as const, sampledAt: longAgo, evidence: null },
}} />
</ReactFlowProvider>
);
expect(screen.getByTestId("activity-staleness-stale-node").textContent).toBe("stale");
});
it("PL-019: when running with currentQitems, the hover hint includes 'On: <short tail> — <excerpt>'", () => {
render(
<ReactFlowProvider>
<RigNode data={{
logicalId: "busy-node",
role: "worker",
runtime: "claude-code",
model: null,
status: "running",
startupStatus: "ready" as const,
binding: null,
agentActivity: { state: "running", reason: "x", evidenceSource: "pane_heuristic" as const, sampledAt: new Date().toISOString(), evidence: null },
currentQitems: [
{ qitemId: "qitem-20260504001234-tail9999", bodyExcerpt: "Phase B audit", tier: "mode2" },
],
}} />
</ReactFlowProvider>
);
// Hover hint sits in the rig-node title attribute (composite tooltip);
// also rendered as a separate hidden block. We assert the ULID tail
// appears at least once within the rendered DOM.
expect(document.body.innerHTML).toContain("tail9999");
expect(document.body.innerHTML).toContain("Phase B audit");
});
it("node actions are visible on the card surface when session commands are available", () => {
const data = {
logicalId: "dev.impl",
@@ -968,7 +1057,9 @@ describe("RigGraph SSE integration", () => {
// Fire SSE message
act(() => {
const es = instances[instances.length - 1]!;
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
// new useTopologyEdgeActivity). Target the rig-events one by URL.
const es = instances.find((i) => i.url.includes("?rigId="))!;
es.simulateMessage('{"type":"node.added"}');
});
@@ -998,7 +1089,9 @@ describe("RigGraph SSE integration", () => {
// Fire SSE message to trigger refetch
act(() => {
const es = instances[instances.length - 1]!;
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
// new useTopologyEdgeActivity). Target the rig-events one by URL.
const es = instances.find((i) => i.url.includes("?rigId="))!;
es.simulateMessage('{"type":"node.added"}');
});
@@ -1017,7 +1110,7 @@ describe("RigGraph SSE integration", () => {
await waitFor(() => expect(instances.length).toBeGreaterThan(0));
act(() => {
instances[instances.length - 1]!.simulateError();
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
});
await waitFor(() => {
@@ -1035,7 +1128,7 @@ describe("RigGraph SSE integration", () => {
// Error
act(() => {
instances[instances.length - 1]!.simulateError();
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
});
await waitFor(() => {
@@ -1047,7 +1140,7 @@ describe("RigGraph SSE integration", () => {
// Reconnect (open event)
act(() => {
instances[instances.length - 1]!.simulateOpen();
instances.find((i) => i.url.includes("?rigId="))!.simulateOpen();
});
await waitFor(() => {
@@ -0,0 +1,74 @@
// PL-019 item 6: topology edge-activity event normalization. The hook
// itself is tested in render-tree tests below; here we drill into the
// normalize step that maps each tracked event-type's payload shape onto
// the unified (source, dest) directed pair.
import { describe, it, expect } from "vitest";
import { __test_internals } from "../src/hooks/useTopologyEdgeActivity.js";
const { normalizeEvent, makeKey, JUST_FIRED_WINDOW_MS, RECENT_TRAFFIC_WINDOW_MS } = __test_internals;
describe("PL-019 topology edge-activity normalization", () => {
it("queue.created maps sourceSession + destinationSession", () => {
const out = normalizeEvent({
type: "queue.created",
sourceSession: "alpha@r",
destinationSession: "beta@r",
qitemId: "q1",
});
expect(out).toEqual({ type: "queue.created", source: "alpha@r", dest: "beta@r" });
});
it("queue.handed_off maps fromSession + toSession (handoff direction)", () => {
const out = normalizeEvent({
type: "queue.handed_off",
fromSession: "alpha@r",
toSession: "gamma@r",
qitemId: "q2",
});
expect(out).toEqual({ type: "queue.handed_off", source: "alpha@r", dest: "gamma@r" });
});
it("qitem.fallback_routed maps originalDestination + rerouteDestination (the new owner edge lights up)", () => {
const out = normalizeEvent({
type: "qitem.fallback_routed",
originalDestination: "alpha@r",
rerouteDestination: "gamma@r",
qitemId: "q3",
reason: "no_pod_match",
});
expect(out).toEqual({ type: "qitem.fallback_routed", source: "alpha@r", dest: "gamma@r" });
});
it("returns null for unknown / non-PL-019 event types", () => {
expect(normalizeEvent({ type: "rig.created", rigId: "r-1" })).toBeNull();
expect(normalizeEvent({ type: "node.startup_completed", rigId: "r-1" })).toBeNull();
// mission_control.action_executed is intentionally not mapped at v0
// because the event payload lacks a destination session pair.
expect(normalizeEvent({ type: "mission_control.action_executed", actorSession: "a@r" })).toBeNull();
});
it("returns null when required pair fields are missing", () => {
expect(normalizeEvent({ type: "queue.created", sourceSession: "alpha@r" })).toBeNull();
expect(normalizeEvent({ type: "queue.handed_off", fromSession: "alpha@r" })).toBeNull();
expect(normalizeEvent({ type: "qitem.fallback_routed", originalDestination: "alpha@r" })).toBeNull();
});
it("returns null on malformed input (non-object / missing type)", () => {
expect(normalizeEvent(null)).toBeNull();
expect(normalizeEvent(undefined)).toBeNull();
expect(normalizeEvent("not-an-object")).toBeNull();
expect(normalizeEvent({ sourceSession: "x", destinationSession: "y" })).toBeNull();
});
it("makeKey produces directional keys (alpha→beta != beta→alpha)", () => {
expect(makeKey("alpha@r", "beta@r")).not.toBe(makeKey("beta@r", "alpha@r"));
});
it("just-fired window is shorter than recent-traffic window (one-shot ⊂ sustained)", () => {
expect(JUST_FIRED_WINDOW_MS).toBeLessThan(RECENT_TRAFFIC_WINDOW_MS);
// Sanity: design guidance says one-shot ~1.2s, sustained ~30s.
expect(JUST_FIRED_WINDOW_MS).toBe(1_200);
expect(RECENT_TRAFFIC_WINDOW_MS).toBe(30_000);
});
});