Skip to content

Commit 159a0eb

Browse files
edgeheroclaude
andcommitted
chore(plan-exec): 2026-07-21-admin-pi-extension — phase "Phase 4: writes, agent tools, dashboard" passed gates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3npQvQXLWsW6qsrRMAmNL
1 parent 1145273 commit 159a0eb

16 files changed

Lines changed: 1067 additions & 92 deletions

admin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"test": "node --test \"test/*.test.mjs\""
88
},
99
"dependencies": {
10-
"@pi-dispatch/worker": "*"
10+
"@pi-dispatch/worker": "*",
11+
"typebox": "1.1.38"
1112
},
1213
"devDependencies": {
1314
"@earendil-works/pi-coding-agent": "0.80.7"

admin/src/dashboard.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* The live `/dispatch` dashboard overlay: a self-refreshing TUI panel over the same read-model the slash
3+
* commands use. It holds ONE queue and ONE redis client for its whole lifetime and polls them on a fixed
4+
* interval -- the self-closing read-model wrappers are one-shots for a single command, so a per-second
5+
* tick through them would open and drop a connection every second.
6+
*
7+
* The render never blocks on I/O: a background fetch writes the latest snapshot and the component always
8+
* renders the last one, so a slow or unreachable queue degrades the panel rather than freezing it. A
9+
* fetch already in flight suppresses the next tick's fetch, so a stall cannot stack overlapping reads.
10+
*
11+
* PII discipline (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT): the panel shows PII-free run records,
12+
* counts, budget, schedulers and the settings overlay only. Raw `.log` bytes are never read here -- that
13+
* surface belongs to the logs overlay viewer in index.ts alone.
14+
*/
15+
import { dayKey } from "@pi-dispatch/worker/budget";
16+
import { parseConnection, makeRedisClient } from "@pi-dispatch/worker/connection";
17+
import { makeQueue } from "@pi-dispatch/worker/queue";
18+
import { listRuns, readSettingsView, mapSchedulers } from "./read-model.mjs";
19+
import { renderStatus, renderBudget, renderRuns, renderSchedulers, renderSettingsView } from "./render.mjs";
20+
import { matchesKey } from "./keys.mjs";
21+
22+
const KEY_HINTS = "[p]ause [r]esume [q]uit";
23+
const RUNS_ON_DASHBOARD = 10;
24+
const REFRESH_MS = 1000;
25+
26+
/**
27+
* Build the read/act/close deps for a live dashboard from resolved paths: ONE failFast queue and ONE
28+
* redis client, both created here and closed once in `dispose`. `fetchSnapshot` reads the whole panel in
29+
* one pass off those held connections; `pause`/`resume` flip the durable paused state on the same queue.
30+
* `getWorkers` is EMPTY on Redis providers without CLIENT SETNAME, so an error or empty list degrades to
31+
* "unknown" rather than reporting zero live workers.
32+
*/
33+
export function createDashboardDeps(paths: any) {
34+
const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
35+
const redis = makeRedisClient(paths.valkeyUrl);
36+
return {
37+
async fetchSnapshot() {
38+
const [pausedState, counts, workerList, reservedRaw, schedulerList] = await Promise.all([
39+
queue.isPaused(),
40+
queue.getJobCounts("waiting", "active", "paused", "delayed", "failed"),
41+
queue.getWorkers().catch(() => []),
42+
redis.get(dayKey()),
43+
queue.getJobSchedulers(0, -1, true),
44+
]);
45+
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
46+
return {
47+
queue: { pausedState, counts, workers },
48+
budget: { reserved: Number(reservedRaw ?? 0) },
49+
schedulers: mapSchedulers(schedulerList, Date.now()),
50+
runs: listRuns({ logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }),
51+
settings: readSettingsView({ settingsFile: paths.settingsFile }),
52+
};
53+
},
54+
async pause() {
55+
await queue.pause();
56+
},
57+
async resume() {
58+
await queue.resume();
59+
},
60+
async dispose() {
61+
try {
62+
await queue.close();
63+
} catch {
64+
// best-effort teardown
65+
}
66+
try {
67+
redis.disconnect();
68+
} catch {
69+
// best-effort teardown
70+
}
71+
},
72+
};
73+
}
74+
75+
/**
76+
* The dashboard overlay component. `deps` is the one injection seam: production defaults to a real
77+
* `createDashboardDeps(paths)` (one queue + one redis for the panel's lifetime); tests pass a canned
78+
* `fetchSnapshot` and `pause`/`resume`/`dispose` spies and never touch Redis. The first fetch fires
79+
* immediately so the panel is populated before the first interval tick; every fetch requests a re-render.
80+
*/
81+
export function makeDashboard({
82+
paths,
83+
done,
84+
tui,
85+
intervalMs = REFRESH_MS,
86+
deps = createDashboardDeps(paths),
87+
}: any = {}) {
88+
let snapshot: any = null;
89+
let fetching = false;
90+
let disposed = false;
91+
let interval: any = null;
92+
93+
const refresh = async () => {
94+
if (fetching || disposed) return;
95+
fetching = true;
96+
try {
97+
snapshot = await deps.fetchSnapshot();
98+
} catch (err: any) {
99+
snapshot = { unreachable: err?.message ?? String(err) };
100+
} finally {
101+
fetching = false;
102+
tui?.requestRender?.();
103+
}
104+
};
105+
106+
const act = async (action: () => Promise<void>) => {
107+
try {
108+
await action();
109+
} catch {
110+
// A failed pause/resume surfaces as the next snapshot's paused state; never crash the overlay.
111+
}
112+
await refresh();
113+
};
114+
115+
const dispose = async () => {
116+
if (disposed) return;
117+
disposed = true;
118+
if (interval !== null) {
119+
clearInterval(interval);
120+
interval = null;
121+
}
122+
try {
123+
await deps.dispose();
124+
} catch {
125+
// best-effort teardown
126+
}
127+
};
128+
129+
interval = setInterval(() => void refresh(), intervalMs);
130+
void refresh();
131+
132+
const component = {
133+
render(_width: number): string[] {
134+
return renderDashboard(snapshot);
135+
},
136+
invalidate(): void {
137+
// No cached render state to clear; the TUI redraws from render().
138+
},
139+
handleInput(data: string): void {
140+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
141+
void dispose().finally(() => done(undefined));
142+
return;
143+
}
144+
if (data === "p" || data === "P") {
145+
void act(deps.pause);
146+
return;
147+
}
148+
if (data === "r" || data === "R") {
149+
void act(deps.resume);
150+
}
151+
},
152+
dispose,
153+
};
154+
return component;
155+
}
156+
157+
/**
158+
* Compose the panel text from the last snapshot alone, reusing the slash-command renderers so the panel
159+
* and the commands cannot drift. A null snapshot is the pre-first-fetch state; a snapshot carrying
160+
* `unreachable` degrades the whole panel to one line rather than a wall of empty sections.
161+
*/
162+
function renderDashboard(snapshot: any): string[] {
163+
if (snapshot === null) {
164+
return ["pi-dispatch dashboard -- loading...", "", KEY_HINTS];
165+
}
166+
if (snapshot.unreachable) {
167+
return [`pi-dispatch dashboard -- unreachable (${snapshot.unreachable})`, "", KEY_HINTS];
168+
}
169+
const blocks = [
170+
"pi-dispatch dashboard",
171+
renderStatus(snapshot.queue),
172+
renderBudget({ budget: snapshot.budget, settings: snapshot.settings }),
173+
renderRuns(snapshot.runs),
174+
renderSchedulers(snapshot.schedulers),
175+
renderSettingsView(snapshot.settings),
176+
KEY_HINTS,
177+
];
178+
return blocks.join("\n\n").split("\n");
179+
}

0 commit comments

Comments
 (0)