Files
stack/packages/control-board/tests/scan.test.mjs
T
jason.woltjeandClaude Fable 5.1 4a7e16c3ec Show task, active project and workspace per control board row (#1503)
Gate A fix asked by the professor session on Jason's behalf. Each row
now carries three derived fields, shown as "unknown" when the log and
tmux do not hold them:

- task: the session's first user message (pi logs have no task envelope)
- workspace: the live pane path of the pane running pi, else session cwd
- activeProject: basename of the nearest git checkout above the workspace

tmuxInspect replaces the bare liveness call in the CLI and returns
{ alive, workspace }; tmuxIsAlive stays as a wrapper. The grouping column
and seen.json keys are unchanged. Fixture test per field, tmux parse
tests, page test; missing launcher signals are recorded in the plan page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:52:45 -05:00

885 lines
42 KiB
JavaScript

import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
utimesSync,
readFileSync,
readdirSync,
existsSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
import { spawnSync } from "node:child_process";
import {
loadConfig,
ConfigError,
findNewestSession,
readSession,
deriveState,
scanAgent,
discoverRepoAgents,
discoverFleetAgents,
scan,
panesRunPi,
parsePanes,
tmuxInspect,
tmuxIsAlive,
findRepoRoot,
loadSeen,
markSeen,
} from "../src/scan.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-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, blocks }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
if (blocks) message.content = (message.content || []).concat(blocks);
return JSON.stringify({ type: "message", timestamp, message });
}
const TOOL_CALL = { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } };
// Same transform readSession applies, kept here as an independent
// expectation rather than importing an unexported helper.
function collapseExpected(text) {
const one = text.replace(/\s+/g, " ").trim();
return one.length > 240 ? one.slice(0, 239) + "…" : one;
}
function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) {
const path = join(dir, name);
writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : ""));
return path;
}
// ---------------------------------------------------------------------------
// 1. loadConfig fail-closed
// ---------------------------------------------------------------------------
test("loadConfig: missing file throws ConfigError", () => {
const root = makeRoot();
assert.throws(() => loadConfig(join(root, "nope.json")), ConfigError);
});
test("loadConfig: invalid JSON throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, "{ not json");
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: missing dataRoot throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, JSON.stringify({}));
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: relative dataRoot throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, JSON.stringify({ dataRoot: "relative/path" }));
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: valid config returns dataRoot", () => {
const root = makeRoot();
const path = join(root, "config.json");
const dataRoot = join(root, "data");
writeFile(path, JSON.stringify({ dataRoot }));
assert.deepEqual(loadConfig(path), { dataRoot });
});
// ---------------------------------------------------------------------------
// 2. findNewestSession
// ---------------------------------------------------------------------------
test("findNewestSession: picks the newest by mtime among two files", () => {
const root = makeRoot();
const older = join(root, "older.jsonl");
const newer = join(root, "newer.jsonl");
writeFile(older, "{}\n");
writeFile(newer, "{}\n");
utimesSync(older, new Date("2026-01-01T00:00:00Z"), new Date("2026-01-01T00:00:00Z"));
utimesSync(newer, new Date("2026-01-02T00:00:00Z"), new Date("2026-01-02T00:00:00Z"));
assert.equal(findNewestSession(root), newer);
});
test("findNewestSession: finds files in nested subdirectories", () => {
const root = makeRoot();
const nested = join(root, "a", "b");
mkdirSync(nested, { recursive: true });
const top = join(root, "top.jsonl");
const deep = join(nested, "deep.jsonl");
writeFile(top, "{}\n");
writeFile(deep, "{}\n");
utimesSync(top, new Date("2026-01-01T00:00:00Z"), new Date("2026-01-01T00:00:00Z"));
utimesSync(deep, new Date("2026-01-05T00:00:00Z"), new Date("2026-01-05T00:00:00Z"));
assert.equal(findNewestSession(root), deep);
});
test("findNewestSession: returns null for a missing dir", () => {
const root = makeRoot();
assert.equal(findNewestSession(join(root, "does-not-exist")), null);
});
// ---------------------------------------------------------------------------
// 3. readSession
// ---------------------------------------------------------------------------
test("readSession: extracts fields, collapses/truncates text, counts a truncated final line", () => {
const root = makeRoot();
const longText = " Field report: " + "alpha ".repeat(60) + " omega ";
const expectedText = collapseExpected(["First part.", longText].join("\n"));
assert.ok(expectedText.length === 240 && expectedText.endsWith("…"), "fixture text must exceed the 240-char limit");
const lines = [
sessionLine({ id: "sess-1", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "user", texts: ["hi"] }),
messageLine({
timestamp: "2026-09-01T00:10:00.000Z",
role: "assistant",
stopReason: "stop",
texts: ["First part.", longText],
}),
// Truncated / partially-written final line: not valid JSON, no trailing newline.
'{"type":"message","timestamp":"2026-09-01T00:15:00.000Z","message":{"role":"assistant","stopReaso',
];
const path = writeSessionFile(root, "session.jsonl", lines, { trailingNewline: false });
const result = readSession(path);
assert.equal(result.file, path);
assert.equal(result.sessionId, "sess-1");
assert.equal(result.cwd, "/x");
assert.equal(result.lastTimestamp, "2026-09-01T00:10:00.000Z");
assert.equal(result.lastMessage.role, "assistant");
assert.equal(result.lastMessage.stopReason, "stop");
assert.equal(result.lastAssistantText, expectedText);
assert.equal(result.lastAssistantText.length, 240);
assert.ok(result.lastAssistantText.endsWith("…"));
assert.equal(result.skippedLines, 1);
});
test("readSession: lastError carries the assistant errorMessage only when the last assistant turn errored", () => {
const root = makeRoot();
const errored = JSON.stringify({
type: "message",
timestamp: "2026-09-01T00:10:00.000Z",
message: { role: "assistant", stopReason: "error", errorMessage: "429: usage limit\nreached", content: [] },
});
const path = writeSessionFile(root, "err.jsonl", [
sessionLine({ id: "sess-e", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "assistant", stopReason: "stop", texts: ["fine"] }),
errored,
]);
const result = readSession(path);
assert.equal(result.lastError, "429: usage limit reached");
assert.equal(result.lastAssistantText, "fine");
const recovered = writeSessionFile(root, "ok.jsonl", [
sessionLine({ id: "sess-o", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
errored,
messageLine({ timestamp: "2026-09-01T00:15:00.000Z", role: "assistant", stopReason: "stop", texts: ["back"] }),
]);
assert.equal(readSession(recovered).lastError, null);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir: root, tmux: { socket: null, session: "a" } }, { isAlive: () => true });
assert.equal(rec.lastError, null);
});
test("findNewestSession/scan: never read sibling auth or secrets next to a sessions dir", () => {
const root = makeRoot();
const agentDir = join(root, "agent-x", ".pi", "agent");
const sessionsDir = join(agentDir, "sessions", "nested");
writeFile(join(agentDir, "auth.json"), '{"token":"SECRET-AUTH-TOKEN"}');
writeFile(join(root, "agent-x", "secrets", "seat.json"), '{"token":"SECRET-SEAT-TOKEN"}');
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "sess-s", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "assistant", stopReason: "stop", texts: ["ok"] }),
]);
assert.equal(findNewestSession(join(agentDir, "sessions")), join(sessionsDir, "s.jsonl"));
const boardDir = join(root, "board");
scan([{ agent: "agent-x", project: "p", sessionsDir: join(agentDir, "sessions"), tmux: { socket: null, session: "agent-x" } }], { boardDir, isAlive: () => true });
const written = [readFileSync(join(boardDir, "index.json"), "utf8"), readFileSync(join(boardDir, "sessions", "p", "agent-x.json"), "utf8")].join("\n");
assert.ok(!written.includes("SECRET-"), "board output must not contain secret material");
assert.ok(!written.includes("auth.json") && !written.includes("secrets/"), "board output must not reference auth or secrets paths");
});
// ---------------------------------------------------------------------------
// 4. deriveState table
// ---------------------------------------------------------------------------
test("deriveState: full state table", () => {
const cases = [
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
{ name: "alive=null (liveness not checkable) is unknown, never assumed alive", input: { alive: null, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "unknown" },
];
for (const c of cases) {
assert.equal(deriveState(c.input), c.expected, c.name);
}
});
// ---------------------------------------------------------------------------
// 4b. Acceptance rule: a seat mid-tool-call is working, never waiting
// (docs/plans/2026-09-12_control-board-mvp.md). Fixtures mirror real pi logs.
// ---------------------------------------------------------------------------
function ruleSpec(root) {
return { agent: "a", project: "p", sessionsDir: join(root, "sessions"), tmux: {} };
}
test("rule: newest entry is an assistant message with a tool call, after a question-looking text, is working", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["go"] }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", texts: ["Should I run the suite now?"], blocks: [TOOL_CALL] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "working");
assert.equal(rec.waitingOnYou, false);
assert.equal(rec.lastAssistantText, "Should I run the suite now?", "the question is still shown, it just does not mean waiting");
});
test("rule: newest entry is a tool result with no assistant text after it is working", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", blocks: [{ type: "thinking", thinking: "x" }, TOOL_CALL] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "toolResult", texts: ["file1\nfile2"] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "working");
assert.equal(rec.waitingOnYou, false);
});
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "toolUse", blocks: [TOOL_CALL] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "toolResult", texts: ["ok"] }),
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "waiting");
assert.equal(rec.waitingOnYou, true);
});
// ---------------------------------------------------------------------------
// 4c. Gate A: task / workspace / active project per row, derived or "unknown"
// (docs/plans/2026-09-12_control-board-mvp.md, Step 3 log). One fixture per field.
// ---------------------------------------------------------------------------
const GATE_NOW = () => new Date("2026-09-12T14:00:10Z");
test("task: the first user message of the session, from text blocks", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466"] }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "stop", texts: ["On it."] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "user", texts: ["and then report back"] }),
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, "[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466");
assert.equal(rec.taskSource, "first-user-message");
});
test("task: a plain-string user content is accepted, whitespace collapsed and long text capped", () => {
const root = makeRoot();
const long = "orchestrate " + "x".repeat(300);
const line = JSON.stringify({ type: "message", timestamp: "2026-09-12T14:00:01Z", message: { role: "user", content: " " + long + "\n\n" } });
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
line,
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, collapseExpected(long));
});
test("task: no user message in the log means null (shown as unknown), never a guess", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" })]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, null);
assert.equal(rec.taskSource, null);
const none = scanAgent(ruleSpec(makeRoot()), { isAlive: () => true, now: GATE_NOW });
assert.equal(none.task, null);
});
test("workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/from/log" })]);
const fromTmux = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: "/from/tmux" }), now: GATE_NOW });
assert.equal(fromTmux.workspace, "/from/tmux");
assert.equal(fromTmux.workspaceSource, "tmux-pane");
assert.equal(fromTmux.alive, true);
assert.equal(fromTmux.cwd, "/from/log");
const fromLog = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(fromLog.workspace, "/from/log");
assert.equal(fromLog.workspaceSource, "session-cwd");
const offline = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: false, workspace: null }), now: GATE_NOW });
assert.equal(offline.state, "offline");
assert.equal(offline.workspace, "/from/log");
const nothing = scanAgent(ruleSpec(makeRoot()), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(nothing.workspace, null);
assert.equal(nothing.workspaceSource, null);
});
test("activeProject: basename of the nearest .git directory or .git file above the workspace; none means null", () => {
const root = makeRoot();
const repo = join(root, "repos", "my-repo");
mkdirSync(join(repo, ".git"), { recursive: true });
mkdirSync(join(repo, "packages", "deep"), { recursive: true });
const worktree = join(root, "repos", "my-worktree");
mkdirSync(join(worktree, "sub"), { recursive: true });
writeFileSync(join(worktree, ".git"), "gitdir: /elsewhere\n");
const plain = join(root, "plain", "dir");
mkdirSync(plain, { recursive: true });
assert.equal(findRepoRoot(join(repo, "packages", "deep")), repo);
assert.equal(findRepoRoot(join(worktree, "sub")), worktree);
assert.equal(findRepoRoot("relative/path"), null);
const inRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(repo, "packages", "deep") }), now: GATE_NOW });
assert.equal(inRepo.activeProject, "my-repo");
const inWorktree = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(worktree, "sub") }), now: GATE_NOW });
assert.equal(inWorktree.activeProject, "my-worktree");
// The group column (spec.project) is untouched; seen.json keys depend on it.
assert.equal(inWorktree.project, "p");
const noRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: plain }), now: GATE_NOW });
// A temp dir may sit under a git checkout on some machines; only assert when it does not.
if (findRepoRoot(plain) === null) assert.equal(noRepo.activeProject, null);
const noWorkspace = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(noWorkspace.activeProject, null);
});
test("scan: the written record carries task, workspace and activeProject", () => {
const root = makeRoot();
const boardDir = join(root, "board");
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["resume"] }),
]);
const index = scan([{ agent: "a", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
const rec = index.sessions[0];
assert.equal(rec.task, "resume");
assert.equal(rec.workspace, "/w");
assert.equal("activeProject" in rec, true);
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
assert.equal(onDisk.task, "resume");
assert.equal(onDisk.workspace, "/w");
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------
test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", () => {
const root = makeRoot();
const waitingDir = join(root, "waiting", "sessions");
const workingDir = join(root, "working", "sessions");
const errorDir = join(root, "error", "sessions");
mkdirSync(waitingDir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
mkdirSync(errorDir, { recursive: true });
writeSessionFile(waitingDir, "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"] }),
]);
writeSessionFile(workingDir, "s.jsonl", [
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "toolUse", texts: ["thinking"] }),
]);
writeSessionFile(errorDir, "s.jsonl", [
sessionLine({ id: "s3", timestamp: "2026-09-01T00:00:00Z", cwd: "/e" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "aborted" }),
]);
const opts = { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") };
const waiting = scanAgent({ agent: "a", project: "p", sessionsDir: waitingDir, tmux: {} }, opts);
const working = scanAgent({ agent: "a", project: "p", sessionsDir: workingDir, tmux: {} }, opts);
const errored = scanAgent({ agent: "a", project: "p", sessionsDir: errorDir, tmux: {} }, opts);
assert.equal(waiting.state, "waiting");
assert.equal(waiting.waitingOnYou, true);
assert.equal(working.state, "working");
assert.equal(working.waitingOnYou, false);
assert.equal(errored.state, "error");
assert.equal(errored.waitingOnYou, true);
});
test("scanAgent: ageSeconds is computed from the injected now", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:00.000Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
]);
const now = new Date(Date.parse("2026-09-01T00:00:00.000Z") + 90_000);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => now });
assert.equal(rec.ageSeconds, 90);
assert.equal(rec.scannedAt, now.toISOString());
});
test("scanAgent: sessionFile null and state idle when sessions dir is empty but alive", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date() });
assert.equal(rec.sessionFile, null);
assert.equal(rec.state, "idle");
assert.equal(rec.alive, true);
assert.equal(rec.sessionId, null);
assert.equal(rec.cwd, null);
assert.equal(rec.lastAssistantText, null);
});
// ---------------------------------------------------------------------------
// 6. discoverRepoAgents / discoverFleetAgents
// ---------------------------------------------------------------------------
test("discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name", () => {
const root = makeRoot();
const repoRoot = join(root, "repo");
mkdirSync(join(repoRoot, ".pi", "state", "b", "sessions"), { recursive: true });
mkdirSync(join(repoRoot, ".pi", "state", "a", "sessions"), { recursive: true });
mkdirSync(join(repoRoot, ".pi", "state", "c"), { recursive: true }); // no sessions dir: must be skipped
const specs = discoverRepoAgents(repoRoot);
assert.deepEqual(specs.map((s) => s.agent), ["a", "b"]);
const project = basename(resolve(repoRoot));
for (const spec of specs) {
assert.equal(spec.project, project);
assert.equal(spec.sessionsDir, join(repoRoot, ".pi", "state", spec.agent, "sessions"));
assert.deepEqual(spec.tmux, { socket: null, session: spec.agent });
}
});
test("discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields", () => {
const root = makeRoot();
const fleetRoot = join(root, "fleet");
mkdirSync(join(fleetRoot, "y", ".pi", "agent", "sessions"), { recursive: true });
mkdirSync(join(fleetRoot, "x", ".pi", "agent", "sessions"), { recursive: true });
mkdirSync(join(fleetRoot, "z", ".pi"), { recursive: true }); // no sessions dir: must be skipped
const specs = discoverFleetAgents(fleetRoot);
assert.deepEqual(specs.map((s) => s.agent), ["x", "y"]);
for (const spec of specs) {
assert.equal(spec.project, "fleet");
assert.equal(spec.sessionsDir, join(fleetRoot, spec.agent, ".pi", "agent", "sessions"));
assert.deepEqual(spec.tmux, { socket: "mosaic-fleet", session: spec.agent });
}
});
// ---------------------------------------------------------------------------
// 7. scan
// ---------------------------------------------------------------------------
test("scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files", () => {
const root = makeRoot();
const boardDir = join(root, "board");
const sessionsDir = join(root, "src-sessions");
mkdirSync(sessionsDir, { recursive: true });
const sessionPath = join(sessionsDir, "s.jsonl");
writeFile(
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
].join("\n") + "\n"
);
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const opts = { boardDir, isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") };
const index1 = scan(specs, opts);
const agentFile = join(boardDir, "sessions", "proj", "agent1.json");
const indexFile = join(boardDir, "index.json");
assert.ok(existsSync(agentFile));
assert.ok(existsSync(indexFile));
const rec1 = JSON.parse(readFileSync(agentFile, "utf8"));
assert.equal(rec1.state, "waiting");
assert.equal(index1.counts.waiting, 1);
assert.equal(index1.counts.working, 0);
assert.deepEqual(index1.waitingOnYou, ["proj/agent1"]);
assert.equal(index1.generatedAt, "2026-09-01T00:01:00.000Z");
// Flip the fixture to a "working" state and rescan; files must reflect the new state.
writeFile(
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:02:00Z", role: "assistant", stopReason: "toolUse", texts: ["thinking"] }),
].join("\n") + "\n"
);
const index2 = scan(specs, { ...opts, now: () => new Date("2026-09-01T00:03:00Z") });
const rec2 = JSON.parse(readFileSync(agentFile, "utf8"));
assert.equal(rec2.state, "working");
assert.equal(index2.counts.working, 1);
assert.equal(index2.counts.waiting, 0);
assert.deepEqual(index2.waitingOnYou, []);
// No leftover *.tmp-* artifacts anywhere under boardDir.
const walk = (dir) => {
const names = readdirSync(dir, { withFileTypes: true });
for (const entry of names) {
const p = join(dir, entry.name);
assert.ok(!entry.name.includes(".tmp-"), `leftover tmp file: ${p}`);
if (entry.isDirectory()) walk(p);
}
};
walk(boardDir);
});
test("scan: relative boardDir throws ConfigError", () => {
assert.throws(() => scan([], { boardDir: "relative/board", isAlive: () => true }), ConfigError);
});
// ---------------------------------------------------------------------------
// 8. CLI
// ---------------------------------------------------------------------------
function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
}
test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files", () => {
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 });
writeFile(
join(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"] }),
].join("\n") + "\n"
);
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^board: /m);
const boardDir = join(dataRoot, "board");
assert.ok(existsSync(join(boardDir, "index.json")));
assert.ok(existsSync(join(boardDir, "sessions", "repo", "agent1.json")));
const index = JSON.parse(readFileSync(join(boardDir, "index.json"), "utf8"));
assert.equal(index.sessions.length, 1);
assert.equal(index.counts.waiting, 1);
});
test("CLI: missing config exits 2 with a refused: message", () => {
const root = makeRoot();
const r = runCli(["scan", "--config", join(root, "no-such-config.json")]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: unknown command exits 2", () => {
const r = runCli(["bogus"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: unknown --liveness value exits 2", () => {
const r = runCli(["scan", "--liveness", "bogus"]);
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}\t#{pane_current_path}"]);
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}\t#{pane_current_path}"]);
});
test("parsePanes: one pane per line, command and optional tab-separated path", () => {
assert.deepEqual(parsePanes("bash\t/home/x\npi\t/mnt/repo\n"), [
{ command: "bash", path: "/home/x" },
{ command: "pi", path: "/mnt/repo" },
]);
assert.deepEqual(parsePanes("pi\n"), [{ command: "pi", path: null }]);
assert.deepEqual(parsePanes(""), []);
});
test("tmuxInspect: reports the path of the pane running pi, not of a shell pane", () => {
const exec = fakeExec({ status: 0, stdout: "bash\t/home/x\npi\t/mnt/repo/sub\n" });
assert.deepEqual(tmuxInspect({ socket: "mosaic-fleet", session: "a" }, { exec }), { alive: true, workspace: "/mnt/repo/sub" });
assert.deepEqual(exec.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=a", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
});
test("tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness", () => {
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 0, stdout: "bash\t/home/x\n" }) }), { alive: false, workspace: null });
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 1, stdout: "" }) }), { alive: false, workspace: null });
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ error: new Error("ENOENT") }) }), { alive: null, workspace: null });
});
// ---------------------------------------------------------------------------
// 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);
});