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:
@@ -9,6 +9,7 @@ import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
discoverRepoAgents,
|
||||
discoverFleetAgents,
|
||||
scan,
|
||||
panesRunPi,
|
||||
tmuxIsAlive,
|
||||
loadSeen,
|
||||
markSeen,
|
||||
} from "../src/scan.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -464,3 +469,229 @@ test("CLI: unknown --liveness value exits 2", () => {
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /^refused:/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. panesRunPi / tmuxIsAlive: liveness means a pane actually runs pi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("panesRunPi: true when any trimmed line equals 'pi'", () => {
|
||||
assert.equal(panesRunPi("bash\npi\n"), true);
|
||||
assert.equal(panesRunPi("pi"), true);
|
||||
assert.equal(panesRunPi(" pi \n"), true);
|
||||
});
|
||||
|
||||
test("panesRunPi: false for bash-only, claude, empty, or node-pi-style lines", () => {
|
||||
assert.equal(panesRunPi("bash"), false);
|
||||
assert.equal(panesRunPi("claude"), false);
|
||||
assert.equal(panesRunPi(""), false);
|
||||
assert.equal(panesRunPi("node pi"), false);
|
||||
});
|
||||
|
||||
function fakeExec(result) {
|
||||
const calls = [];
|
||||
const exec = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return result;
|
||||
};
|
||||
exec.calls = calls;
|
||||
return exec;
|
||||
}
|
||||
|
||||
test("tmuxIsAlive: a pane running pi is alive", () => {
|
||||
const exec = fakeExec({ status: 0, stdout: "bash\npi\n" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), true);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: session exists but pi has exited is not alive", () => {
|
||||
const exec = fakeExec({ status: 0, stdout: "bash\n" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: no such tmux session is not alive", () => {
|
||||
const exec = fakeExec({ status: 1, stdout: "" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive", () => {
|
||||
const exec = fakeExec({ error: new Error("ENOENT") });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), null);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: passes -L <socket> only when a socket is given", () => {
|
||||
const withSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: "mosaic-fleet", session: "name" }, { exec: withSocket });
|
||||
assert.equal(withSocket.calls[0].cmd, "tmux");
|
||||
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
|
||||
const noSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: null, session: "name" }, { exec: noSocket });
|
||||
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Seen marks: loadSeen / markSeen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("loadSeen: missing file returns {}", () => {
|
||||
const root = makeRoot();
|
||||
assert.deepEqual(loadSeen(root), {});
|
||||
});
|
||||
|
||||
test("loadSeen: invalid JSON throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), "{ not json");
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("loadSeen: a JSON array throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), "[]");
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("loadSeen: a non-string value throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), JSON.stringify({ "p/a": 123 }));
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "2026-09-01T00:00:00Z", seen: true });
|
||||
assert.deepEqual(marks, { "p/a": "2026-09-01T00:00:00Z" });
|
||||
const path = join(boardDir, "seen.json");
|
||||
assert.ok(existsSync(path));
|
||||
assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
const names = readdirSync(boardDir);
|
||||
assert.ok(!names.some((n) => n.includes(".tmp-")), `leftover tmp file among: ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
test("markSeen: seen false deletes the key", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: true });
|
||||
const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: false });
|
||||
assert.deepEqual(marks, {});
|
||||
});
|
||||
|
||||
test("markSeen: missing, empty, or non-string fields throw ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: 5, lastActivity: "t" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { agent: "a", lastActivity: "t" }), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: project containing '/' throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p/x", agent: "a", lastActivity: "t" }), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: non-boolean seen throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t", seen: "true" }), ConfigError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. scanAgent: seen marks apply only to waiting/error, and only while the
|
||||
// mark's timestamp still matches the agent's newest message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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 seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.seen, true);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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 seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.seen, false);
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
});
|
||||
|
||||
test("scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error)", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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: "toolUse", texts: ["thinking"] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "working");
|
||||
assert.equal(rec.seen, false);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("scanAgent: an error-state session with a matching mark is seen", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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: "aborted" }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "error");
|
||||
assert.equal(rec.seen, true);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. scan: reads seen.json, index reflects it, and scanning never rewrites it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const lastActivity = "2026-09-01T00:00:01Z";
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
]);
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
const seenPath = join(boardDir, "seen.json");
|
||||
writeFile(seenPath, JSON.stringify({ "p/agent1": lastActivity }));
|
||||
const before = readFileSync(seenPath);
|
||||
|
||||
const index = scan([{ agent: "agent1", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") });
|
||||
assert.deepEqual(index.seen, ["p/agent1"]);
|
||||
assert.deepEqual(index.waitingOnYou, []);
|
||||
|
||||
const after = readFileSync(seenPath);
|
||||
assert.deepEqual(before, after, "scan must never rewrite seen.json");
|
||||
});
|
||||
|
||||
test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
writeFile(join(boardDir, "seen.json"), "{ bad json");
|
||||
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user