Add control board web page and local server (#1503)
Step 2 of the control board MVP (MOSAIC-STACK-D-001): `serve` command starts a loopback-only local server that serves one self-contained page and re-runs the status scanner on each /api/board request. The page lists sessions waiting on Jason first (errors on top), then one table per project with plain-word states, ages, last messages, expandable detail rows, per-project hide-offline, and a 10-second auto-refresh with pause. Tests: control-board 33/33 (10 new: loopback rules, host refusal, all routes, per-request rescan, 500 path, CLI refusals, live serve, page escaping guard); registry 69/69 unchanged. Receipt: docs/plans/reviews/2026-09-12_control-board-step2-review.md. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -1943,3 +1943,25 @@ Suites: control-board 23/23; registry 69/69 unchanged. Live scan writes
|
|||||||
Corrections during the phase: pi `stopReason: "length"` was initially treated as
|
Corrections during the phase: pi `stopReason: "length"` was initially treated as
|
||||||
"working" (now "error"); an unrunnable tmux check was initially treated as alive
|
"working" (now "error"); an unrunnable tmux check was initially treated as alive
|
||||||
(now "unknown"). Registry increment 3 stays parked. Next: step 2, the web page.
|
(now "unknown"). Registry increment 3 stays parked. Next: step 2, the web page.
|
||||||
|
|
||||||
|
## 2026-09-12 — Control board MVP step 2 (#1503)
|
||||||
|
|
||||||
|
Before: scanner and CLI only; status lived in JSON files under
|
||||||
|
`<dataRoot>/board/` with no way to look at them but `--print`.
|
||||||
|
|
||||||
|
After: `node packages/control-board/src/cli.mjs serve` starts a local,
|
||||||
|
loopback-only server (default http://127.0.0.1:7331/) that serves one
|
||||||
|
self-contained page and re-runs the scanner on every `/api/board` request.
|
||||||
|
The page lists "waiting on you" first (errors on top), then one table per
|
||||||
|
project with plain-word states, ages and last messages; rows expand to full
|
||||||
|
detail; offline rows are hidden per project by default; it refreshes every
|
||||||
|
10 seconds and can be paused. Suites: control-board 33/33 (10 new server and
|
||||||
|
page tests); registry 69/69 unchanged. Live check in Chrome with the full fleet:
|
||||||
|
18 waiting rows, 24 offline hidden, expanded detail survives a refresh.
|
||||||
|
Receipt: `docs/plans/reviews/2026-09-12_control-board-step2-review.md`.
|
||||||
|
|
||||||
|
Corrections during the phase: `startServer` first threw synchronously for a
|
||||||
|
non-loopback host (now always rejects); expanded rows first closed on every
|
||||||
|
refresh (now persisted); the browser reported a favicon 404 (now a 204 route).
|
||||||
|
The independent reviewer had no browser, so the live check was done by the
|
||||||
|
coordinator and is recorded as such. Next: step 3, Jason uses the page daily.
|
||||||
|
|||||||
@@ -212,3 +212,5 @@ are never rewritten or removed; corrections are new entries.
|
|||||||
- 2026-09-12 — dewey (Claude Code; owner-directed takeover of dewey pi session 01a08111) — Resumed WUI Phase 2 from agents/dewey/work/wui/TASKS.md: D01 source-linked feature inventory, then D02-D05 five dashboard mockups and comparison page, folding in MOSAIC-STACK-D-001 control board as the landing view. Prototype files only under agents/dewey/work/wui; no v1 inspection, backend, credentials, commit or push.
|
- 2026-09-12 — dewey (Claude Code; owner-directed takeover of dewey pi session 01a08111) — Resumed WUI Phase 2 from agents/dewey/work/wui/TASKS.md: D01 source-linked feature inventory, then D02-D05 five dashboard mockups and comparison page, folding in MOSAIC-STACK-D-001 control board as the landing view. Prototype files only under agents/dewey/work/wui; no v1 inspection, backend, credentials, commit or push.
|
||||||
|
|
||||||
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 1 (#1503, D-001): plan page, ticket, `packages/control-board` scanner + CLI, 23/23 tests, live scan 42 sessions/18 waiting on Jason; independent review CHANGES REQUIRED then APPROVED after three fixes (length→error, unknown liveness state, secrets-boundary test). Receipt docs/plans/reviews/2026-09-12_control-board-step1-review.md. CURRENT next action: step 2, the page.
|
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 1 (#1503, D-001): plan page, ticket, `packages/control-board` scanner + CLI, 23/23 tests, live scan 42 sessions/18 waiting on Jason; independent review CHANGES REQUIRED then APPROVED after three fixes (length→error, unknown liveness state, secrets-boundary test). Receipt docs/plans/reviews/2026-09-12_control-board-step1-review.md. CURRENT next action: step 2, the page.
|
||||||
|
- 2026-09-12 — dewey (Claude Code; takeover of pi session 01a08111) — WUI Phase 2 D01-D04 done, D05 delivered: five dashboard mockups (Rail, Deck, Console, Board, Stream) plus comparison at agents/dewey/work/wui/mockups/index.html; verifier 31 checks, 0 failed, 2 not verified (forced colours, screen reader); records in VERIFY.md, README.md, TASKS.md, HISTORY.md, DECISIONS.md. Stopped at Jason's design-selection gate. No v1 inspection, backend, credentials, commit or push.
|
||||||
|
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 2 (#1503, D-001): `packages/control-board` gains `serve` (loopback-only local server, `/api/board` re-runs the scanner) and a single-file page (waiting-on-you first, per-project groups, hide-offline, expandable rows, 10s auto-refresh with pause). Suites: control-board 33/33, registry 69/69. Static review (sonnet) found no defects; live browser check by coordinator. Receipt docs/plans/reviews/2026-09-12_control-board-step2-review.md. CURRENT next action: step 3, daily use by Jason.
|
||||||
|
|||||||
@@ -83,12 +83,20 @@ Board files are derived. They can be deleted and rebuilt at any time by
|
|||||||
running the scanner again. They are NOT run records and they are not
|
running the scanner again. They are NOT run records and they are not
|
||||||
evidence under the repository's write-once rules.
|
evidence under the repository's write-once rules.
|
||||||
|
|
||||||
## Command
|
## Commands
|
||||||
|
|
||||||
|
Scan once and print the table:
|
||||||
|
|
||||||
```
|
```
|
||||||
node packages/control-board/src/cli.mjs scan --print
|
node packages/control-board/src/cli.mjs scan --print
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Start the page (step 2), then open http://127.0.0.1:7331/ in a browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
node packages/control-board/src/cli.mjs serve
|
||||||
|
```
|
||||||
|
|
||||||
Tests:
|
Tests:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+13
-13
@@ -10,20 +10,20 @@ update this file to the next action). No ambiguity, no re-planning.
|
|||||||
Jason decided on 2026-09-12 (MOSAIC-STACK-D-001) that the MVP is the control
|
Jason decided on 2026-09-12 (MOSAIC-STACK-D-001) that the MVP is the control
|
||||||
board: one web page listing running agent sessions across projects, showing each
|
board: one web page listing running agent sessions across projects, showing each
|
||||||
one's status, and flagging which ones are waiting on him. Plan page:
|
one's status, and flagging which ones are waiting on him. Plan page:
|
||||||
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Step 1 (status
|
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Steps 1 and 2
|
||||||
scanner `packages/control-board`, plan page, ticket) is done in this checkout;
|
are done in this checkout: `packages/control-board` scans every Pi agent
|
||||||
running `node packages/control-board/src/cli.mjs scan --print` writes one status
|
(repo `.pi/state/*` and `~/.mosaic/fleet`) and serves the page.
|
||||||
file per agent under `<dataRoot>/board/`. The registry line (increment 3,
|
|
||||||
headless identity-env leak) stays parked; #1500 is closed.
|
|
||||||
|
|
||||||
Next action: step 2 of #1503 — the web page. One local page that reads
|
Start it with `node packages/control-board/src/cli.mjs serve` and open
|
||||||
`<dataRoot>/board/index.json`, groups sessions by project, shows the plain-word
|
http://127.0.0.1:7331/ (loopback only, no auth, no daemon; Ctrl-C stops it).
|
||||||
state per agent, puts a "waiting on you" section at the top, and refreshes
|
|
||||||
itself (re-running the scanner on each refresh is acceptable). Reuse Dewey's
|
Next action: step 3 of #1503 — daily use. Jason opens the page while he works
|
||||||
control-board mockups under `agents/dewey/work/wui/` for layout where they fit.
|
and reports what is wrong, missing or confusing; each report becomes a small
|
||||||
Acceptance: Jason opens the page in a browser and sees the same rows the
|
fix on this package with a test, committed and pushed to `refactor`. Close
|
||||||
`--print` output shows, with no manual steps beyond starting the page. No auth,
|
#1503 when Jason says the page answers "who is waiting on me" without him
|
||||||
no hooks/plugins, no comms, no launcher changes, no new root files.
|
opening a terminal. Out of scope until he asks: auth, hooks/plugins, comms,
|
||||||
|
memory, launcher changes, non-Pi harnesses, new root files. The registry line
|
||||||
|
(increment 3, headless identity-env leak) stays parked; #1500 is closed.
|
||||||
|
|
||||||
## Completed checkpoint: #1500 increment 2 (historical)
|
## Completed checkpoint: #1500 increment 2 (historical)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Control board MVP step 2: the page, implementation and review receipt
|
||||||
|
|
||||||
|
Date: 2026-09-12. Recorded by jarvis (Claude Code, coordinator). Tracking: #1503.
|
||||||
|
Plan: `docs/plans/2026-09-12_control-board-mvp.md`. Step 1 receipt:
|
||||||
|
`2026-09-12_control-board-step1-review.md`.
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- `packages/control-board/src/serve.mjs`: a small local web server with no
|
||||||
|
dependencies and no auth. Routes: `/` (the page), `/api/board` (re-runs the
|
||||||
|
scanner and returns `index.json`), `/healthz`, `/favicon.ico` (empty). It
|
||||||
|
refuses to bind to anything but a loopback address.
|
||||||
|
- `packages/control-board/src/page.html`: one self-contained page. "Waiting on
|
||||||
|
you" at the top (error rows first), then one table per project with a
|
||||||
|
plain-word state badge, age, and last message. Rows expand to a detail panel;
|
||||||
|
open panels and the per-project "hide offline" choice survive the 10-second
|
||||||
|
auto-refresh. Pause/Resume and a manual Refresh button. All API text is
|
||||||
|
escaped before it reaches the page.
|
||||||
|
- `src/cli.mjs`: new `serve` command (`--port`, `--host`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`node --test packages/control-board/tests/` — 33 tests, 33 pass, 0 fail
|
||||||
|
(23 scanner tests from step 1, 10 new: loopback rules, host refusal without a
|
||||||
|
socket, every route, per-request rescan, 500 path, CLI refusal/exit codes, a
|
||||||
|
live spawned `serve`, and a static check that the page's esc() helper escapes
|
||||||
|
every HTML-significant character and that no API value is joined into markup
|
||||||
|
without it).
|
||||||
|
|
||||||
|
## Live check in a real browser
|
||||||
|
|
||||||
|
Server started with the full fleet and real tmux liveness on port 7332; page
|
||||||
|
opened in Chrome through DevTools. Seen: 18 rows under "Waiting on you" with
|
||||||
|
the three fleet 429-error rows first; two project groups (mosaic-stack 4,
|
||||||
|
fleet 38) with "24 offline hidden"; unticking "Hide offline" showed the
|
||||||
|
offline rows; Pause changed the header to "auto-refresh paused"; a row toggle
|
||||||
|
opened its detail panel and stayed open across the next refresh. Console:
|
||||||
|
only a favicon 404 (a `/favicon.ico` route was added after; no page errors).
|
||||||
|
Screenshot: `/tmp/control-board-review.png` (not committed).
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
Reviewer: in-session code-review agent (Claude Sonnet), static read-only.
|
||||||
|
Findings: none at reportable confidence for XSS, host-check bypass, request
|
||||||
|
handling, fail-closed behaviour, or doc drift. It could not run the browser
|
||||||
|
check itself (no shell or browser tools in its session), so it withheld a
|
||||||
|
final approval on that ground; the browser check above was done by the
|
||||||
|
coordinator instead. Two minor notes acted on: a stale test comment, and
|
||||||
|
adding a test for the page's escaping. A separate test-writing helper flagged
|
||||||
|
that `startServer` threw synchronously for a bad host while other failures
|
||||||
|
rejected; it is now `async` so every failure is a rejection.
|
||||||
|
|
||||||
|
Corrections during this step: open detail panels originally closed on every
|
||||||
|
refresh (fixed, keyed by section/project/agent); the escaping guard test
|
||||||
|
initially flagged my own key-building line and a comment (reworded).
|
||||||
|
|
||||||
|
## Boundaries kept
|
||||||
|
|
||||||
|
No launcher changes, no new root files, no secrets, no auth (loopback only),
|
||||||
|
no daemon, no changes to `packages/mosaic`. Next: step 3, daily use by Jason.
|
||||||
@@ -18,12 +18,19 @@ rewritable; they are not run records and are not evidence.
|
|||||||
| idle | The agent is live but has not had a conversation yet. |
|
| idle | The agent is live but has not had a conversation yet. |
|
||||||
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
|
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
|
||||||
|
|
||||||
## Command
|
## Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||||
|
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`scan` runs once and writes the status files. `serve` starts a small local
|
||||||
|
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
|
||||||
|
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
|
||||||
|
never staler than that timer. There is no login, so the server refuses to
|
||||||
|
bind to anything but a loopback address.
|
||||||
|
|
||||||
- `--config PATH` — path to the system config file. Defaults to
|
- `--config PATH` — path to the system config file. Defaults to
|
||||||
`~/.config/mosaic-dev/config.json`. This file must exist and name an
|
`~/.config/mosaic-dev/config.json`. This file must exist and name an
|
||||||
absolute `dataRoot`, or the scanner refuses to run.
|
absolute `dataRoot`, or the scanner refuses to run.
|
||||||
@@ -35,11 +42,17 @@ node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liven
|
|||||||
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
|
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
|
||||||
`tmux` (default) checks the real tmux session. `assume-alive` treats
|
`tmux` (default) checks the real tmux session. `assume-alive` treats
|
||||||
every agent as alive, useful for tests or environments without tmux.
|
every agent as alive, useful for tests or environments without tmux.
|
||||||
- `--print` — also print a one-line-per-agent table to stdout.
|
- `--print` — (`scan`) also print a one-line-per-agent table to stdout.
|
||||||
|
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
|
||||||
|
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
|
||||||
|
Any non-loopback address is refused.
|
||||||
|
|
||||||
|
Routes served: `/` (the page), `/api/board` (rescan, returns `index.json`),
|
||||||
|
`/healthz`.
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
- `0` — scan completed and status files were written.
|
- `0` — scan completed and status files were written, or the server stopped cleanly.
|
||||||
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
|
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
|
||||||
The message on stderr says why.
|
The message on stderr says why.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Usage: node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
// Usage:
|
||||||
|
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||||
|
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||||
// Exit 0 on success, 2 on a config refusal or bad usage.
|
// Exit 0 on success, 2 on a config refusal or bad usage.
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
|
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
|
||||||
|
import { startServer } from "./serve.mjs";
|
||||||
|
|
||||||
|
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
|
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
|
||||||
for (let i = 1; i < argv.length; i++) {
|
for (let i = 1; i < argv.length; i++) {
|
||||||
const a = argv[i];
|
const a = argv[i];
|
||||||
const next = () => {
|
const next = () => {
|
||||||
@@ -18,19 +23,32 @@ function parseArgs(argv) {
|
|||||||
else if (a === "--fleet") opts.fleet = next();
|
else if (a === "--fleet") opts.fleet = next();
|
||||||
else if (a === "--liveness") opts.liveness = next();
|
else if (a === "--liveness") opts.liveness = next();
|
||||||
else if (a === "--print") opts.print = true;
|
else if (a === "--print") opts.print = true;
|
||||||
|
else if (a === "--port") {
|
||||||
|
opts.port = Number(next());
|
||||||
|
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
|
||||||
|
} else if (a === "--host") opts.host = next();
|
||||||
else throw new ConfigError(`unknown argument: ${a}`);
|
else throw new ConfigError(`unknown argument: ${a}`);
|
||||||
}
|
}
|
||||||
if (opts.command !== "scan") throw new ConfigError("usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]");
|
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
|
||||||
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
async function main() {
|
||||||
const opts = parseArgs(process.argv.slice(2));
|
const opts = parseArgs(process.argv.slice(2));
|
||||||
const { dataRoot } = loadConfig(opts.config);
|
const { dataRoot } = loadConfig(opts.config);
|
||||||
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
||||||
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
|
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
|
||||||
const boardDir = join(dataRoot, "board");
|
const boardDir = join(dataRoot, "board");
|
||||||
|
if (opts.command === "serve") {
|
||||||
|
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
|
||||||
|
const addr = server.address();
|
||||||
|
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||||
|
const stop = () => server.close(() => process.exit(0));
|
||||||
|
process.on("SIGINT", stop);
|
||||||
|
process.on("SIGTERM", stop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const index = scan(specs, { boardDir, isAlive });
|
const index = scan(specs, { boardDir, isAlive });
|
||||||
if (opts.print) {
|
if (opts.print) {
|
||||||
for (const s of index.sessions) {
|
for (const s of index.sessions) {
|
||||||
@@ -42,12 +60,10 @@ function main() {
|
|||||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
|
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
main().catch((err) => {
|
||||||
main();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ConfigError) {
|
if (err instanceof ConfigError) {
|
||||||
process.stderr.write(`refused: ${err.message}\n`);
|
process.stderr.write(`refused: ${err.message}\n`);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Control board</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
|
||||||
|
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
|
||||||
|
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
|
||||||
|
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
|
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||||
|
--radius:8px;--radius-lg:14px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root{
|
||||||
|
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
|
||||||
|
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
|
||||||
|
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*,*::before,*::after{box-sizing:border-box}
|
||||||
|
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
|
||||||
|
body{margin:0 auto;max-width:1100px;padding:16px}
|
||||||
|
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
|
||||||
|
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
|
||||||
|
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
|
||||||
|
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
|
||||||
|
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||||
|
.status-line{margin:0;color:var(--muted);font-size:.9rem}
|
||||||
|
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
|
||||||
|
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
|
||||||
|
.btn:hover{background:var(--raised)}
|
||||||
|
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
|
||||||
|
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
|
||||||
|
.empty{color:var(--muted);font-style:italic}
|
||||||
|
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
||||||
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
|
||||||
|
tbody tr:last-child td{border-bottom:none}
|
||||||
|
tr.is-offline{opacity:.55}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
|
||||||
|
border-radius:99px;border:1px solid transparent;white-space:nowrap}
|
||||||
|
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||||
|
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
|
||||||
|
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
|
||||||
|
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
|
||||||
|
.badge-muted{color:var(--muted);background:var(--raised)}
|
||||||
|
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||||
|
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
||||||
|
cursor:pointer;text-align:left}
|
||||||
|
.row-toggle:hover{text-decoration:underline}
|
||||||
|
.msg-error{color:var(--danger)}
|
||||||
|
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.detail-row td{background:var(--raised)}
|
||||||
|
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||||
|
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||||
|
.detail-list dd{margin:0;overflow-wrap:anywhere}
|
||||||
|
.project-group{margin-bottom:20px}
|
||||||
|
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||||
|
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
|
||||||
|
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="page-head">
|
||||||
|
<h1>Control board</h1>
|
||||||
|
<div class="head-controls">
|
||||||
|
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
|
||||||
|
<button id="refreshBtn" class="btn" type="button">Refresh</button>
|
||||||
|
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
|
||||||
|
<main id="main">
|
||||||
|
<section aria-labelledby="waiting-h">
|
||||||
|
<h2 id="waiting-h">Waiting on you</h2>
|
||||||
|
<div id="waitingBody"></div>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="projects-h">
|
||||||
|
<h2 id="projects-h">By project</h2>
|
||||||
|
<div id="projectsBody"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer id="footer" class="page-footer"></footer>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
|
||||||
|
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
|
||||||
|
var REFRESH_MS = 10000;
|
||||||
|
|
||||||
|
var headerStatus = document.getElementById("headerStatus");
|
||||||
|
var refreshBtn = document.getElementById("refreshBtn");
|
||||||
|
var pauseBtn = document.getElementById("pauseBtn");
|
||||||
|
var errorBanner = document.getElementById("errorBanner");
|
||||||
|
var waitingBody = document.getElementById("waitingBody");
|
||||||
|
var projectsBody = document.getElementById("projectsBody");
|
||||||
|
var footer = document.getElementById("footer");
|
||||||
|
var main = document.getElementById("main");
|
||||||
|
|
||||||
|
var lastData = null;
|
||||||
|
var paused = false;
|
||||||
|
var fetching = false;
|
||||||
|
var timerId = null;
|
||||||
|
var secondsLeft = REFRESH_MS / 1000;
|
||||||
|
var hideOfflineState = {};
|
||||||
|
// Open detail panels survive a refresh. Keyed by section, project and agent
|
||||||
|
// because the same agent can appear in both the waiting list and its group.
|
||||||
|
var openDetails = {};
|
||||||
|
var rowIdx = 0;
|
||||||
|
|
||||||
|
function esc(v) {
|
||||||
|
if (v === null || v === undefined) return "";
|
||||||
|
return String(v).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanAge(s) {
|
||||||
|
if (s === null || s === undefined) return "—";
|
||||||
|
if (s < 60) return s + "s";
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + "m";
|
||||||
|
var h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return h + "h";
|
||||||
|
return Math.floor(h / 24) + "d";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
if (!iso) return "never";
|
||||||
|
var ms = Date.now() - Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return "never";
|
||||||
|
var s = Math.floor(ms / 1000);
|
||||||
|
if (s < 5) return "just now";
|
||||||
|
if (s < 60) return s + "s ago";
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + "m ago";
|
||||||
|
var h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return h + "h ago";
|
||||||
|
return Math.floor(h / 24) + "d ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
|
function badge(state) {
|
||||||
|
var cls = BADGE_CLASS[state] || "badge-muted";
|
||||||
|
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowPair(rec, showProject) {
|
||||||
|
var idx = rowIdx++;
|
||||||
|
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||||
|
var open = !!openDetails[key];
|
||||||
|
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||||
|
var msg = rec.state === "error" && rec.lastError
|
||||||
|
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||||
|
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||||
|
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||||
|
var main =
|
||||||
|
'<tr class="' + cls + '">' +
|
||||||
|
projectCell +
|
||||||
|
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button></td>" +
|
||||||
|
"<td>" + badge(rec.state) + "</td>" +
|
||||||
|
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||||
|
"<td>" + msg + "</td>" +
|
||||||
|
"</tr>";
|
||||||
|
var span = showProject ? 5 : 4;
|
||||||
|
var tmux = rec.tmux && rec.tmux.session
|
||||||
|
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
|
||||||
|
: "—";
|
||||||
|
var detail =
|
||||||
|
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
|
||||||
|
'<dl class="detail-list">' +
|
||||||
|
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||||
|
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||||
|
"<dt>Working directory</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||||
|
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||||
|
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||||
|
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||||
|
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||||
|
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||||
|
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||||
|
"</dl></td></tr>";
|
||||||
|
return main + detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWaiting(data) {
|
||||||
|
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
|
||||||
|
list.sort(function (a, b) {
|
||||||
|
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
|
||||||
|
if (ae !== be) return ae - be;
|
||||||
|
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||||
|
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||||
|
return aa - bb;
|
||||||
|
});
|
||||||
|
if (list.length === 0) {
|
||||||
|
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||||
|
waitingBody.innerHTML =
|
||||||
|
'<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
|
||||||
|
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectOrder(a, b) {
|
||||||
|
if (a === "fleet" && b === "fleet") return 0;
|
||||||
|
if (a === "fleet") return 1;
|
||||||
|
if (b === "fleet") return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects(data) {
|
||||||
|
var sessions = data.sessions || [];
|
||||||
|
var byProject = {};
|
||||||
|
sessions.forEach(function (r) {
|
||||||
|
(byProject[r.project] = byProject[r.project] || []).push(r);
|
||||||
|
});
|
||||||
|
var projects = Object.keys(byProject).sort(projectOrder);
|
||||||
|
if (projects.length === 0) {
|
||||||
|
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectsBody.innerHTML = projects.map(function (project) {
|
||||||
|
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
|
||||||
|
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
|
||||||
|
var hideOffline = hideOfflineState[project];
|
||||||
|
var visible = group.filter(function (r) { return !(hideOffline && r.state === "offline"); });
|
||||||
|
var hiddenCount = group.length - visible.length;
|
||||||
|
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
|
||||||
|
var note = hiddenCount > 0 ? '<p class="offline-note">' + hiddenCount + " offline hidden</p>" : "";
|
||||||
|
return (
|
||||||
|
'<div class="project-group">' +
|
||||||
|
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + group.length + ")</span></h3>" +
|
||||||
|
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
|
||||||
|
"</tr></thead><tbody>" + (rows || '<tr><td colspan="4" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
|
||||||
|
note + "</div>"
|
||||||
|
);
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(data) {
|
||||||
|
var counts = data.counts || {};
|
||||||
|
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
||||||
|
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
rowIdx = 0;
|
||||||
|
renderWaiting(lastData);
|
||||||
|
renderProjects(lastData);
|
||||||
|
renderFooter(lastData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderStatus() {
|
||||||
|
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
|
||||||
|
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
|
||||||
|
headerStatus.textContent = "Updated " + updated + " · " + refresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBanner.hidden = false;
|
||||||
|
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
errorBanner.hidden = true;
|
||||||
|
errorBanner.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextFetch() {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
if (paused) return;
|
||||||
|
secondsLeft = REFRESH_MS / 1000;
|
||||||
|
timerId = setTimeout(runFetch, REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFetch() {
|
||||||
|
if (fetching) return;
|
||||||
|
fetching = true;
|
||||||
|
fetch("/api/board", { cache: "no-store" })
|
||||||
|
.then(function (res) {
|
||||||
|
return res.json().catch(function () {
|
||||||
|
throw new Error("the server sent a response that was not valid JSON");
|
||||||
|
}).then(function (body) {
|
||||||
|
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (body) {
|
||||||
|
lastData = body;
|
||||||
|
hideError();
|
||||||
|
renderAll();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error("control board refresh failed", err);
|
||||||
|
showError(err && err.message ? err.message : "unknown error");
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
fetching = false;
|
||||||
|
renderHeaderStatus();
|
||||||
|
scheduleNextFetch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaused(p) {
|
||||||
|
paused = p;
|
||||||
|
pauseBtn.textContent = paused ? "Resume" : "Pause";
|
||||||
|
pauseBtn.setAttribute("aria-pressed", String(paused));
|
||||||
|
if (paused) clearTimeout(timerId);
|
||||||
|
else scheduleNextFetch();
|
||||||
|
renderHeaderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
main.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest(".row-toggle");
|
||||||
|
if (!btn) return;
|
||||||
|
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
||||||
|
var expanded = btn.getAttribute("aria-expanded") === "true";
|
||||||
|
btn.setAttribute("aria-expanded", String(!expanded));
|
||||||
|
detail.hidden = expanded;
|
||||||
|
if (expanded) delete openDetails[btn.dataset.key];
|
||||||
|
else openDetails[btn.dataset.key] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
main.addEventListener("change", function (e) {
|
||||||
|
var cb = e.target.closest(".hide-offline-toggle");
|
||||||
|
if (!cb) return;
|
||||||
|
hideOfflineState[cb.dataset.project] = cb.checked;
|
||||||
|
renderProjects(lastData);
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", runFetch);
|
||||||
|
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
if (!paused && secondsLeft > 0) secondsLeft -= 1;
|
||||||
|
renderHeaderStatus();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
renderHeaderStatus();
|
||||||
|
runFetch();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Control board step 2: a tiny local web server. No dependencies, no auth.
|
||||||
|
// It only ever binds to a loopback address (fail closed otherwise).
|
||||||
|
//
|
||||||
|
// GET / the page (src/page.html)
|
||||||
|
// GET /api/board re-runs the scanner and returns index.json as JSON
|
||||||
|
// GET /healthz {"ok":true}
|
||||||
|
//
|
||||||
|
// Every /api/board request rescans, so the page is never staler than its
|
||||||
|
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||||
|
|
||||||
|
import { createServer as createHttpServer } from "node:http";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { scan, ConfigError } from "./scan.mjs";
|
||||||
|
|
||||||
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||||
|
|
||||||
|
export function isLoopbackHost(host) {
|
||||||
|
if (LOOPBACK.has(host)) return true;
|
||||||
|
return isIP(host) === 4 && host.startsWith("127.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||||
|
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
|
||||||
|
return createHttpServer((req, res) => {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
|
res.writeHead(405, { "content-type": "text/plain" });
|
||||||
|
return res.end("method not allowed\n");
|
||||||
|
}
|
||||||
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||||
|
return res.end(page);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/board") {
|
||||||
|
let index;
|
||||||
|
try {
|
||||||
|
index = scan(specs, { boardDir, isAlive, now });
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
return res.end(JSON.stringify(index) + "\n");
|
||||||
|
}
|
||||||
|
if (url.pathname === "/favicon.ico") {
|
||||||
|
res.writeHead(204);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
if (url.pathname === "/healthz") {
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end('{"ok":true}\n');
|
||||||
|
}
|
||||||
|
res.writeHead(404, { "content-type": "text/plain" });
|
||||||
|
res.end("not found\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to the listening server. Refuses any non-loopback host.
|
||||||
|
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
|
||||||
|
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
|
||||||
|
const server = createServer(rest);
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolvePromise(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readFileSync,
|
||||||
|
existsSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { spawnSync, spawn } from "node:child_process";
|
||||||
|
import { createServer as createNetServer } from "node:net";
|
||||||
|
import { ConfigError } from "../src/scan.mjs";
|
||||||
|
import { isLoopbackHost, startServer } from "../src/serve.mjs";
|
||||||
|
|
||||||
|
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixture helpers, copied from scan.test.mjs (kept local so that file stays
|
||||||
|
// untouched; do not import unexported helpers across test files).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
|
||||||
|
const roots = [];
|
||||||
|
function makeRoot() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "control-board-serve-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
after(() => {
|
||||||
|
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeFile(path, content) {
|
||||||
|
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||||
|
writeFileSync(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLine({ id, timestamp, cwd }) {
|
||||||
|
return JSON.stringify({ type: "session", id, timestamp, cwd });
|
||||||
|
}
|
||||||
|
function messageLine({ timestamp, role, stopReason, texts }) {
|
||||||
|
const message = { role };
|
||||||
|
if (stopReason !== undefined) message.stopReason = stopReason;
|
||||||
|
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
|
||||||
|
return JSON.stringify({ type: "message", timestamp, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) {
|
||||||
|
const path = join(dir, name);
|
||||||
|
writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : ""));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// serve.mjs-specific helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolvePromise) => server.close(resolvePromise));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab an ephemeral free port, then hand it back immediately so a caller can
|
||||||
|
// try to bind it themselves (used to prove startServer never opened a socket).
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const probe = createNetServer();
|
||||||
|
probe.once("error", reject);
|
||||||
|
probe.listen(0, "127.0.0.1", () => {
|
||||||
|
const port = probe.address().port;
|
||||||
|
probe.close(() => resolvePromise(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args) {
|
||||||
|
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. isLoopbackHost
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("isLoopbackHost: recognizes loopback hosts", () => {
|
||||||
|
assert.equal(isLoopbackHost("127.0.0.1"), true);
|
||||||
|
assert.equal(isLoopbackHost("::1"), true);
|
||||||
|
assert.equal(isLoopbackHost("localhost"), true);
|
||||||
|
assert.equal(isLoopbackHost("127.5.5.5"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isLoopbackHost: rejects non-loopback hosts", () => {
|
||||||
|
assert.equal(isLoopbackHost("0.0.0.0"), false);
|
||||||
|
assert.equal(isLoopbackHost("192.168.1.2"), false);
|
||||||
|
assert.equal(isLoopbackHost("::"), false);
|
||||||
|
assert.equal(isLoopbackHost(""), false);
|
||||||
|
assert.equal(isLoopbackHost("evil.example"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. startServer: fail-closed on a non-loopback host
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const port = await getFreePort();
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
// startServer is async, so the host refusal surfaces as a rejection
|
||||||
|
// before any listen() call happens.
|
||||||
|
await startServer({
|
||||||
|
host: "192.168.1.2",
|
||||||
|
port,
|
||||||
|
specs: [],
|
||||||
|
boardDir: join(root, "board"),
|
||||||
|
isAlive: () => true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
assert.ok(caught instanceof ConfigError, "expected a ConfigError");
|
||||||
|
|
||||||
|
// The port must still be free: startServer must never have called listen().
|
||||||
|
await new Promise((resolvePromise, reject) => {
|
||||||
|
const probe = createNetServer();
|
||||||
|
probe.once("error", reject);
|
||||||
|
probe.listen(port, "127.0.0.1", () => probe.close(resolvePromise));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. createServer routes, via startServer on port 0
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("startServer: serves page, healthz, and a rescanning /api/board", async () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const sessionsDir = join(root, "sessions");
|
||||||
|
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||||
|
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||||
|
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||||
|
]);
|
||||||
|
const boardDir = join(root, "board");
|
||||||
|
const page = "<html><body>injected test page</body></html>";
|
||||||
|
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||||
|
|
||||||
|
const server = await startServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
specs,
|
||||||
|
boardDir,
|
||||||
|
isAlive: () => true,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const path of ["/", "/index.html"]) {
|
||||||
|
const res = await fetch(`${base}${path}`);
|
||||||
|
assert.equal(res.status, 200, path);
|
||||||
|
assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
|
||||||
|
assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||||
|
assert.equal(await res.text(), page, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await fetch(`${base}/healthz`);
|
||||||
|
assert.equal(health.status, 200);
|
||||||
|
assert.deepEqual(await health.json(), { ok: true });
|
||||||
|
|
||||||
|
const first = await fetch(`${base}/api/board`);
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal(first.headers.get("content-type"), "application/json");
|
||||||
|
assert.equal(first.headers.get("cache-control"), "no-store");
|
||||||
|
const firstBody = await first.json();
|
||||||
|
assert.equal(firstBody.sessions[0].state, "waiting");
|
||||||
|
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
|
||||||
|
|
||||||
|
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||||
|
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||||
|
|
||||||
|
// Rewrite the fixture to a "user" last message (working state) and hit
|
||||||
|
// /api/board again: a fresh scan must reflect the new state, proving
|
||||||
|
// each request rescans instead of caching.
|
||||||
|
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||||
|
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||||
|
messageLine({ timestamp: "2026-09-01T00:05:00Z", role: "user", texts: ["go again"] }),
|
||||||
|
]);
|
||||||
|
const second = await fetch(`${base}/api/board`);
|
||||||
|
assert.equal(second.status, 200);
|
||||||
|
const secondBody = await second.json();
|
||||||
|
assert.equal(secondBody.sessions[0].state, "working");
|
||||||
|
|
||||||
|
const missing = await fetch(`${base}/nope`);
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
|
||||||
|
const posted = await fetch(`${base}/api/board`, { method: "POST" });
|
||||||
|
assert.equal(posted.status, 405);
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. /api/board: scan failure surfaces as a 500 with an error field
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("startServer: /api/board returns 500 JSON with an error field when scan throws", async () => {
|
||||||
|
const server = await startServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
specs: [],
|
||||||
|
// Relative boardDir: scan() throws ConfigError("boardDir must be an absolute path").
|
||||||
|
boardDir: "relative/board",
|
||||||
|
isAlive: () => true,
|
||||||
|
page: "<html></html>",
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/board`);
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.equal(res.headers.get("content-type"), "application/json");
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(typeof body.error, "string");
|
||||||
|
assert.ok(body.error.length > 0);
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. CLI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("CLI: serve refuses a non-loopback host with exit 2 and a refused: message", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dataRoot = join(root, "data");
|
||||||
|
const configPath = join(root, "config.json");
|
||||||
|
writeFile(configPath, JSON.stringify({ dataRoot }));
|
||||||
|
const repoRoot = join(root, "repo");
|
||||||
|
mkdirSync(repoRoot, { recursive: true });
|
||||||
|
|
||||||
|
const r = runCli(["serve", "--host", "0.0.0.0", "--config", configPath, "--repo", repoRoot, "--fleet", "none"]);
|
||||||
|
assert.equal(r.status, 2);
|
||||||
|
assert.match(r.stderr, /^refused:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI: serve rejects a non-numeric --port with exit 2", () => {
|
||||||
|
const r = runCli(["serve", "--port", "abc"]);
|
||||||
|
assert.equal(r.status, 2);
|
||||||
|
assert.match(r.stderr, /^refused:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI: scan still works after the async cli refactor", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dataRoot = join(root, "data");
|
||||||
|
const configPath = join(root, "config.json");
|
||||||
|
writeFile(configPath, JSON.stringify({ dataRoot }));
|
||||||
|
const repoRoot = join(root, "repo");
|
||||||
|
mkdirSync(repoRoot, { recursive: true });
|
||||||
|
|
||||||
|
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive"]);
|
||||||
|
assert.equal(r.status, 0, r.stderr);
|
||||||
|
assert.match(r.stdout, /^board: /m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI: live serve prints its URL and answers /healthz", async () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dataRoot = join(root, "data");
|
||||||
|
const configPath = join(root, "config.json");
|
||||||
|
writeFile(configPath, JSON.stringify({ dataRoot }));
|
||||||
|
const repoRoot = join(root, "repo");
|
||||||
|
mkdirSync(repoRoot, { recursive: true });
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cli, "serve", "--port", "0", "--liveness", "assume-alive", "--config", configPath, "--repo", repoRoot, "--fleet", "none"],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdoutBuf = "";
|
||||||
|
let stderrBuf = "";
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderrBuf += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = await new Promise((resolvePromise, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`timed out waiting for the server line; stdout=${JSON.stringify(stdoutBuf)} stderr=${JSON.stringify(stderrBuf)}`));
|
||||||
|
}, 15000);
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdoutBuf += chunk.toString();
|
||||||
|
const match = stdoutBuf.match(/^control board: (http:\/\/127\.0\.0\.1:\d+)\//m);
|
||||||
|
if (match) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolvePromise(match[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`child exited early with code ${code}; stderr=${stderrBuf}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${url}/healthz`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), { ok: true });
|
||||||
|
} finally {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await new Promise((resolvePromise) => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return resolvePromise();
|
||||||
|
child.on("exit", resolvePromise);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page's only XSS defence is its inline esc() helper. Pull that function
|
||||||
|
// out of page.html by name and check it inerts every HTML-significant char.
|
||||||
|
test("page.html: esc() escapes every HTML-significant character", () => {
|
||||||
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
||||||
|
const m = html.match(/function esc\(v\) \{[\s\S]*?\n \}/);
|
||||||
|
assert.ok(m, "esc() must exist in page.html");
|
||||||
|
const esc = new Function(`${m[0]}; return esc;`)();
|
||||||
|
assert.equal(esc('<script>alert("x")</script>&\''), "<script>alert("x")</script>&'");
|
||||||
|
assert.equal(esc(null), "");
|
||||||
|
assert.equal(esc(undefined), "");
|
||||||
|
assert.equal(esc(42), "42");
|
||||||
|
// The page builds HTML by string concatenation. Any API value joined
|
||||||
|
// straight into markup ("+ rec.x" / "+ project" / "+ data.x") would bypass
|
||||||
|
// esc(); require zero such joins so a regression is caught here.
|
||||||
|
const rawJoins = [...html.matchAll(/\+\s*(rec\.[\w.]+|project|data\.[\w.]+)\b(?!\s*\|\|)/g)].map((x) => x[0]);
|
||||||
|
assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
|
||||||
|
const escCalls = (html.match(/\besc\(/g) || []).length;
|
||||||
|
assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user