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:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user