Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)

First step-3 refinement from Jason's daily use. Liveness now lists the
panes of the agent's tmux session and counts it alive only if a pane runs
pi, so killed pi sessions whose tmux session still exists show offline
instead of waiting. A "Seen" button on waiting and error rows stores the
row's lastActivity in <dataRoot>/board/seen.json (clicks only, never
rewritten by a scan, fail closed if corrupt) and drops the row from
"Waiting on you" until the agent writes anything newer; "Unsee" reverses
it. New POST /api/seen route: JSON only, 4 KB limit, 400 on bad input.

Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED;
receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 08:24:49 -05:00
co-authored by Claude Fable 5.1
parent ebedd1281e
commit 88d21defde
12 changed files with 777 additions and 21 deletions
+201 -2
View File
@@ -9,10 +9,10 @@ import {
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join, resolve, basename } from "node:path";
import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError } from "../src/scan.mjs";
import { ConfigError, markSeen } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
@@ -336,3 +336,202 @@ test("page.html: esc() escapes every HTML-significant character", () => {
const escCalls = (html.match(/\besc\(/g) || []).length;
assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
});
// ---------------------------------------------------------------------------
// 6. POST /api/seen
// ---------------------------------------------------------------------------
test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it", 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 specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const postRes = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(postRes.status, 200);
const postBody = await postRes.json();
assert.ok(postBody.seen.includes("proj/agent1"));
assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
const boardRes = await fetch(`${base}/api/board`);
assert.equal(boardRes.status, 200);
const boardBody = await boardRes.json();
assert.ok(boardBody.seen.includes("proj/agent1"));
assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
const clearRes = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z", seen: false }),
});
assert.equal(clearRes.status, 200);
const clearBody = await clearRes.json();
assert.ok(!clearBody.seen.includes("proj/agent1"));
assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
} finally {
await closeServer(server);
}
});
test("POST /api/seen without a JSON content-type returns 400 and does not write a mark", 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 specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "text/plain" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
} finally {
await closeServer(server);
}
});
test("POST /api/seen with invalid JSON returns 400", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")));
} finally {
await closeServer(server);
}
});
test("POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
const big = JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "x".repeat(5000) });
try {
let status = null;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: big,
});
status = res.status;
} catch {
status = null; // a destroyed connection is an acceptable outcome too
}
if (status !== null) assert.equal(status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
} finally {
await closeServer(server);
}
});
test("POST /api/seen with a missing agent returns 400", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")));
} finally {
await closeServer(server);
}
});
test("POST /api/board returns 405; PUT /api/seen returns 405", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const board = await fetch(`${base}/api/board`, { method: "POST" });
assert.equal(board.status, 405);
const put = await fetch(`${base}/api/seen`, { method: "PUT" });
assert.equal(put.status, 405);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 7. CLI: seen rows in --print
// ---------------------------------------------------------------------------
test("CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
const sessionsDir = join(repoRoot, ".pi", "state", "agent1", "sessions");
mkdirSync(sessionsDir, { recursive: true });
const lastActivity = "2026-09-01T00:00:01Z";
writeFile(
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
].join("\n") + "\n"
);
const boardDir = join(dataRoot, "board");
const project = basename(resolve(repoRoot));
markSeen(boardDir, { project, agent: "agent1", lastActivity, seen: true });
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]);
assert.equal(r.status, 0, r.stderr);
const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
assert.equal(row[0], "s");
assert.match(r.stdout, /1 seen\)\s*$/m);
});
// ---------------------------------------------------------------------------
// 8. page.html: the seen-toggle control escapes its values and posts JSON
// ---------------------------------------------------------------------------
test("page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function seenControl\(rec\) \{[\s\S]*?\n \}/);
assert.ok(m, "seenControl() must exist in page.html");
const body = m[0];
assert.match(body, /esc\(rec\.project\)/);
assert.match(body, /esc\(rec\.agent\)/);
assert.match(body, /esc\(rec\.lastActivity\)/);
assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
});