Add control board status scanner and MVP plan (#1503)
Step 1 of the control board MVP (decision MOSAIC-STACK-D-001): a plan page, Gitea #1503, and packages/control-board, which reads each agent's newest pi session log plus tmux liveness and writes one status file per agent under <dataRoot>/board/. 23/23 tests; independent review approved after three fixes (length stopReason as error, unknown liveness state, secrets-boundary test). CURRENT.md now points at step 2, the page. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# control-board
|
||||
|
||||
Step 1 of the control board MVP (`docs/plans/2026-09-12_control-board-mvp.md`).
|
||||
This package scans your running pi agent sessions and writes one small JSON
|
||||
status file per agent, plus one summary file, so a later web page can show
|
||||
them all in one place. It reads pi session logs and checks tmux liveness. It
|
||||
does not launch, stop, or talk to any agent. Board files are derived and
|
||||
rewritable; they are not run records and are not evidence.
|
||||
|
||||
## States
|
||||
|
||||
| State | Plain-words meaning |
|
||||
|---------|----------------------|
|
||||
| working | The agent is in the middle of a turn: thinking or running a tool. |
|
||||
| waiting | The agent finished its turn. It is your move now. |
|
||||
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
|
||||
| offline | There is no live tmux session for this agent right now. |
|
||||
| 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
|
||||
|
||||
```
|
||||
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
```
|
||||
|
||||
- `--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.
|
||||
- `--repo PATH` — path to a project checkout to scan for repo agents
|
||||
(`.pi/state/<agent>/sessions`). Defaults to the current directory.
|
||||
- `--fleet PATH|none` — path to the fleet agents directory
|
||||
(`<fleet>/<agent>/.pi/agent/sessions`, tmux socket `mosaic-fleet`).
|
||||
Defaults to `~/.mosaic/fleet/agents`. Pass `none` to skip fleet agents.
|
||||
- `--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.
|
||||
|
||||
## Exit codes
|
||||
|
||||
- `0` — scan completed and status files were written.
|
||||
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
|
||||
The message on stderr says why.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
node --test packages/control-board/tests/
|
||||
```
|
||||
|
||||
## Output layout
|
||||
|
||||
```
|
||||
<dataRoot>/board/
|
||||
index.json # summary: counts, waiting-on-you list, all records
|
||||
sessions/
|
||||
<project>/
|
||||
<agent>.json # one status record per agent
|
||||
```
|
||||
|
||||
## Example status record
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "darkwing",
|
||||
"project": "mosaic-stack",
|
||||
"state": "waiting",
|
||||
"waitingOnYou": true,
|
||||
"alive": true,
|
||||
"tmux": { "socket": null, "session": "darkwing" },
|
||||
"sessionFile": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions/2026-09-12.jsonl",
|
||||
"sessionId": "01a06e48-0718-71f2-a889-c263c4800fb9",
|
||||
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||
"lastActivity": "2026-09-12T15:04:33.000Z",
|
||||
"ageSeconds": 42,
|
||||
"lastAssistantText": "Ready for the next step whenever you are.",
|
||||
"lastError": null,
|
||||
"skippedLines": 0,
|
||||
"scannedAt": "2026-09-12T15:05:15.000Z"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@mosaic/control-board",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Control board MVP step 1: scan running agent sessions and write one status file per agent.",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": { "node": ">=24" },
|
||||
"bin": { "mosaic-board": "src/cli.mjs" },
|
||||
"exports": {
|
||||
".": "./src/scan.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test tests/"
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/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]
|
||||
// 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";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
if (i + 1 >= argv.length) throw new ConfigError(`missing value for ${a}`);
|
||||
return argv[++i];
|
||||
};
|
||||
if (a === "--config") opts.config = next();
|
||||
else if (a === "--repo") opts.repo = next();
|
||||
else if (a === "--fleet") opts.fleet = next();
|
||||
else if (a === "--liveness") opts.liveness = next();
|
||||
else if (a === "--print") opts.print = true;
|
||||
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 (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
||||
return opts;
|
||||
}
|
||||
|
||||
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");
|
||||
const index = scan(specs, { boardDir, isAlive });
|
||||
if (opts.print) {
|
||||
for (const s of index.sessions) {
|
||||
const flag = s.waitingOnYou ? "*" : " ";
|
||||
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
process.stderr.write(`refused: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Control board step 1: read each agent's newest pi session log and tmux
|
||||
// liveness, and write one small JSON status file per agent.
|
||||
//
|
||||
// States (plain words):
|
||||
// working - the agent is in the middle of a turn (thinking or running tools)
|
||||
// waiting - the agent finished its turn; it is your move
|
||||
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
|
||||
// offline - no live tmux session for this agent
|
||||
// idle - the agent is live but has no conversation yet
|
||||
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
|
||||
//
|
||||
// Board files are derived and rewritable. They are not run records.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
|
||||
const TEXT_LIMIT = 240;
|
||||
|
||||
export class ConfigError extends Error {}
|
||||
|
||||
export function defaultConfigPath() {
|
||||
return join(homedir(), ".config", "mosaic-dev", "config.json");
|
||||
}
|
||||
|
||||
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
|
||||
export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
throw new ConfigError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
}
|
||||
return { dataRoot: raw.dataRoot };
|
||||
}
|
||||
|
||||
// Newest *.jsonl under a directory tree, by mtime. Returns null when none.
|
||||
export function findNewestSession(dir) {
|
||||
if (!existsSync(dir)) return null;
|
||||
let best = null;
|
||||
const walk = (d) => {
|
||||
for (const name of readdirSync(d)) {
|
||||
const p = join(d, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) walk(p);
|
||||
else if (name.endsWith(".jsonl") && (!best || st.mtimeMs > best.mtimeMs)) best = { path: p, mtimeMs: st.mtimeMs };
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return best ? best.path : null;
|
||||
}
|
||||
|
||||
function collapse(text) {
|
||||
const one = text.replace(/\s+/g, " ").trim();
|
||||
return one.length > TEXT_LIMIT ? one.slice(0, TEXT_LIMIT - 1) + "…" : one;
|
||||
}
|
||||
|
||||
// Read a pi session log. A partially written final line is skipped and counted,
|
||||
// not treated as fatal, because pi may be appending while we read.
|
||||
export function readSession(file) {
|
||||
const lines = readFileSync(file, "utf8").split("\n");
|
||||
let sessionId = null, cwd = null, lastTimestamp = null, lastMessage = null, lastAssistantText = null, lastError = null;
|
||||
let skippedLines = 0;
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
skippedLines += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.timestamp) lastTimestamp = entry.timestamp;
|
||||
if (entry.type === "session") {
|
||||
sessionId = entry.id ?? sessionId;
|
||||
cwd = entry.cwd ?? cwd;
|
||||
} else if (entry.type === "message" && entry.message) {
|
||||
lastMessage = entry.message;
|
||||
if (entry.message.role === "assistant" && Array.isArray(entry.message.content)) {
|
||||
const text = entry.message.content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
||||
if (text.trim()) lastAssistantText = collapse(text);
|
||||
lastError = entry.message.stopReason === "error" && typeof entry.message.errorMessage === "string" ? collapse(entry.message.errorMessage) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, skippedLines };
|
||||
}
|
||||
|
||||
// Pure state rule. alive: true/false, or null when liveness could not be checked.
|
||||
// A null check is reported as "unknown" rather than assumed alive (fail closed).
|
||||
export function deriveState({ alive, session }) {
|
||||
if (alive === false) return "offline";
|
||||
if (alive !== true) return "unknown";
|
||||
if (!session || !session.lastMessage) return "idle";
|
||||
const m = session.lastMessage;
|
||||
if (m.role === "assistant") {
|
||||
if (m.stopReason === "stop") return "waiting";
|
||||
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
|
||||
return "working";
|
||||
}
|
||||
return "working";
|
||||
}
|
||||
|
||||
export function tmuxIsAlive({ socket, session }) {
|
||||
const args = [];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("has-session", "-t", `=${session}`);
|
||||
const r = spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
if (r.error) return null;
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
// One agent -> one status record.
|
||||
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() } = {}) {
|
||||
const alive = isAlive(spec.tmux);
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
const session = file ? readSession(file) : null;
|
||||
const state = deriveState({ alive, session });
|
||||
const scannedAt = now();
|
||||
const lastActivity = session?.lastTimestamp ?? null;
|
||||
const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
state,
|
||||
waitingOnYou: state === "waiting" || state === "error",
|
||||
alive,
|
||||
tmux: spec.tmux,
|
||||
sessionFile: file,
|
||||
sessionId: session?.sessionId ?? null,
|
||||
cwd: session?.cwd ?? null,
|
||||
lastActivity,
|
||||
ageSeconds,
|
||||
lastAssistantText: session?.lastAssistantText ?? null,
|
||||
lastError: session?.lastError ?? null,
|
||||
skippedLines: session?.skippedLines ?? 0,
|
||||
scannedAt: scannedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Repo agents: <repo>/.pi/state/<agent>/sessions, tmux default socket, session = agent.
|
||||
export function discoverRepoAgents(repoRoot) {
|
||||
const stateDir = join(repoRoot, ".pi", "state");
|
||||
if (!existsSync(stateDir)) return [];
|
||||
const project = basename(resolve(repoRoot));
|
||||
return readdirSync(stateDir)
|
||||
.filter((n) => existsSync(join(stateDir, n, "sessions")))
|
||||
.sort()
|
||||
.map((agent) => ({ agent, project, sessionsDir: join(stateDir, agent, "sessions"), tmux: { socket: null, session: agent } }));
|
||||
}
|
||||
|
||||
// Fleet agents: <fleet>/<agent>/.pi/agent/sessions, tmux socket mosaic-fleet, session = agent.
|
||||
export function discoverFleetAgents(fleetRoot, { project = "fleet", socket = "mosaic-fleet" } = {}) {
|
||||
if (!existsSync(fleetRoot)) return [];
|
||||
return readdirSync(fleetRoot)
|
||||
.filter((n) => existsSync(join(fleetRoot, n, ".pi", "agent", "sessions")))
|
||||
.sort()
|
||||
.map((agent) => ({ agent, project, sessionsDir: join(fleetRoot, agent, ".pi", "agent", "sessions"), tmux: { socket, session: agent } }));
|
||||
}
|
||||
|
||||
function writeAtomic(path, data) {
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(join(dir, `${rec.agent}.json`), rec);
|
||||
}
|
||||
const generatedAt = (now ? now() : new Date()).toISOString();
|
||||
const index = {
|
||||
generatedAt,
|
||||
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map((r) => `${r.project}/${r.agent}`),
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(join(boardDir, "index.json"), index);
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
} 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,
|
||||
} 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 }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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: "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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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:/);
|
||||
});
|
||||
Reference in New Issue
Block a user