mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
ca4f02ead9
commit
ee39768e8e
19 files changed
+1380
-66
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user