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
|
||||
"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.
|
||||
|
||||
## 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 — 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
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
+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
|
||||
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:
|
||||
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Step 1 (status
|
||||
scanner `packages/control-board`, plan page, ticket) is done in this checkout;
|
||||
running `node packages/control-board/src/cli.mjs scan --print` writes one status
|
||||
file per agent under `<dataRoot>/board/`. The registry line (increment 3,
|
||||
headless identity-env leak) stays parked; #1500 is closed.
|
||||
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Steps 1 and 2
|
||||
are done in this checkout: `packages/control-board` scans every Pi agent
|
||||
(repo `.pi/state/*` and `~/.mosaic/fleet`) and serves the page.
|
||||
|
||||
Next action: step 2 of #1503 — the web page. One local page that reads
|
||||
`<dataRoot>/board/index.json`, groups sessions by project, shows the plain-word
|
||||
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
|
||||
control-board mockups under `agents/dewey/work/wui/` for layout where they fit.
|
||||
Acceptance: Jason opens the page in a browser and sees the same rows the
|
||||
`--print` output shows, with no manual steps beyond starting the page. No auth,
|
||||
no hooks/plugins, no comms, no launcher changes, no new root files.
|
||||
Start it with `node packages/control-board/src/cli.mjs serve` and open
|
||||
http://127.0.0.1:7331/ (loopback only, no auth, no daemon; Ctrl-C stops it).
|
||||
|
||||
Next action: step 3 of #1503 — daily use. Jason opens the page while he works
|
||||
and reports what is wrong, missing or confusing; each report becomes a small
|
||||
fix on this package with a test, committed and pushed to `refactor`. Close
|
||||
#1503 when Jason says the page answers "who is waiting on me" without him
|
||||
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)
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| 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/mosaic-dev/config.json`. This file must exist and name an
|
||||
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.
|
||||
`tmux` (default) checks the real tmux session. `assume-alive` treats
|
||||
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
|
||||
|
||||
- `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.
|
||||
The message on stderr says why.
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
#!/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.
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
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) {
|
||||
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++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
@@ -18,19 +23,32 @@ function parseArgs(argv) {
|
||||
else if (a === "--fleet") opts.fleet = next();
|
||||
else if (a === "--liveness") opts.liveness = next();
|
||||
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}`);
|
||||
}
|
||||
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}`);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const { dataRoot } = loadConfig(opts.config);
|
||||
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
||||
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
|
||||
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 });
|
||||
if (opts.print) {
|
||||
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`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
main().catch((err) => {
|
||||
if (err instanceof ConfigError) {
|
||||
process.stderr.write(`refused: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
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