Add mosaic launch <seat> with seat registration for the control board (#1504)

New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes
<dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's
launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board
reads registrations, matches by sessions directory, and lets a registered
task, project or workspace override the derived value with a source tag.
The four repository launch scripts register themselves unless already
registered or run with --check. Fleet launchers untouched; one-liner on the
plan page.

Review found the record path keyed by seat name alone (repo and fleet
"darkwing" would collide); fixed by keying on layout. Also: the Pi pin
refusal now names installed and required versions.

Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 10:56:17 -05:00
co-authored by Claude Fable 5.1
parent 01d9a19612
commit 69f99323c7
25 changed files with 1766 additions and 46 deletions
+20 -3
View File
@@ -82,7 +82,7 @@ node --test packages/control-board/tests/
```
<dataRoot>/board/
index.json # summary: counts, waiting-on-you list, seen list, all records
index.json # summary: counts, waiting-on-you, seen, registered, registrationErrors, all records
seen.json # Jason's "seen" marks (see below)
sessions/
<project>/
@@ -95,8 +95,9 @@ only when Jason clicks "Seen" or "Unsee" on the page (via `POST
the agent's newest message still has that exact `lastActivity` timestamp —
as soon as the agent writes anything new, `lastActivity` changes, the mark
no longer matches, and the row falls back into "Waiting on you" on its own.
Every row also shows three "what and where" fields, each derived from the
session log and tmux or shown as the word "unknown", never guessed:
Every row also shows three "what and where" fields, each taken from the
seat's registration when it has one, else derived from the session log and
tmux, else shown as the word "unknown", never guessed:
- **Task** — the session's first user message (collapsed to one line, 240
characters). pi logs carry no task envelope, so this is the only
@@ -112,6 +113,20 @@ session log and tmux or shown as the word "unknown", never guessed:
tmux could not be asked, the `cwd` from the session log. The record says
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
A seat started through `scripts/mosaic launch <seat>` (see
`packages/seat/README.md`, #1504) has a registration at
`<dataRoot>/seats/<layout>/<seat>/registration.json`. The scan reads every one of
those and matches a record to a row by its sessions directory, never by
name alone. A registered task, project or workspace replaces the derived
value and the row's source field (`taskSource`, `activeProjectSource`,
`workspaceSource`) reads `registration`; the page shows a small source tag
next to the value and a "Registered" line in the detail with the start time,
harness, pid and tmux session. An empty task or a null project or workspace
in the record leaves the derived value in place. Rows with no registration
are exactly as before. The board only reads `seats/`; `mosaic launch` and
`mosaic seat task` are the only writers. A malformed record is listed in
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
Marked rows are listed under a collapsed "Seen (N)" section on the page,
each with an "Unsee" button, so nothing marked is ever out of reach. Each
project table has "Hide offline" and "Hide seen" checkboxes (both on by
@@ -140,6 +155,8 @@ values), the scan refuses rather than silently dropping every mark.
"workspace": "/mnt/storage/src/mosaic-stack",
"workspaceSource": "tmux-pane",
"activeProject": "mosaic-stack",
"activeProjectSource": "workspace-git-root",
"registered": null,
"lastActivity": "2026-09-12T15:04:33.000Z",
"ageSeconds": 42,
"lastAssistantText": "Ready for the next step whenever you are.",
+5 -3
View File
@@ -40,8 +40,9 @@ async function main() {
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
@@ -49,7 +50,8 @@ async function main() {
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive });
const index = scan(specs, { boardDir, isAlive, seatsDir });
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
if (opts.print) {
for (const s of index.sessions) {
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
@@ -57,7 +59,7 @@ async function main() {
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, ${index.seen.length} seen)\n`);
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered)\n`);
}
main().catch((err) => {
+25 -6
View File
@@ -65,6 +65,7 @@
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
@@ -184,6 +185,21 @@
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
return parts.join("; ");
}
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
@@ -193,12 +209,14 @@
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: derived from the log and tmux, or "unknown". Never guessed.
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>"
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>"
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
@@ -219,9 +237,10 @@
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
+70 -14
View File
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
const TEXT_LIMIT = 240;
@@ -243,15 +244,52 @@ function liveness(result) {
return { alive: result ?? null, workspace: null };
}
// Registrations written by `mosaic launch <seat>` (packages/seat): one
// record per seat under <dataRoot>/seats/<layout>/<seat>/registration.json. Returns
// the readable records plus one error line per unreadable one; a bad record
// must not take the whole board down, but it is not silently dropped either.
export function loadRegistrations(seatsDir) {
const registrations = [];
const errors = [];
if (!seatsDir || !existsSync(seatsDir)) return { registrations, errors };
for (const layout of LAYOUTS) {
const layoutDir = join(seatsDir, layout);
if (!existsSync(layoutDir) || !statSync(layoutDir).isDirectory()) continue;
for (const seat of readdirSync(layoutDir).sort()) {
if (!statSync(join(layoutDir, seat)).isDirectory()) continue;
try {
const rec = readRegistration(seatsDir, seat, layout);
if (rec) registrations.push(rec);
} catch (err) {
if (!(err instanceof SeatError)) throw err;
errors.push(`seat ${layout}/${seat}: ${err.message}`);
}
}
}
return { registrations, errors };
}
// A registration belongs to a row when it names the same sessions directory.
// Seat names alone are not enough: the repo and fleet layouts both have a
// "darkwing", and they are different seats.
export function matchRegistration(spec, registrations) {
return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null;
}
// One agent -> one status record.
//
// Three fields answer "what is this seat doing, and where" (Gate A ask,
// 2026-09-12). Each is derived, never guessed; null means "unknown".
// task the session's first user message. The log has no task
// envelope entry, so this is the only assignment signal it holds.
// workspace the live pane path from tmux, else the session log's cwd.
// activeProject basename of the nearest git repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) {
// 2026-09-12). Each is derived, never guessed; null means "unknown". A seat
// started through `mosaic launch` has a registration, and a registered task,
// project or workspace wins over the derived value; the *Source field says
// which one the row shows.
// task registration.task, else the session's first user message
// (the log has no task envelope entry).
// workspace registration.workspace, else the live pane path from tmux,
// else the session log's cwd.
// activeProject registration.project, else the basename of the nearest git
// repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null } = {}) {
const live = liveness(isAlive(spec.tmux));
const alive = live.alive;
const file = findNewestSession(spec.sessionsDir);
@@ -263,8 +301,17 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null;
const workspace = live.workspace ?? cwd;
const repoRoot = workspace ? findRepoRoot(workspace) : null;
const reg = registration && typeof registration === "object" ? registration : null;
const derivedWorkspace = live.workspace ?? cwd;
const workspace = reg?.workspace ?? derivedWorkspace;
const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null;
const repoRoot = derivedWorkspace ? findRepoRoot(derivedWorkspace) : null;
const derivedProject = repoRoot ? basename(repoRoot) : null;
const activeProject = reg?.project ?? derivedProject;
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
const firstUserText = session?.firstUserText ?? null;
const task = reg?.task ? reg.task : firstUserText;
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
return {
agent: spec.agent,
project: spec.project,
@@ -276,11 +323,15 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
sessionFile: file,
sessionId: session?.sessionId ?? null,
cwd,
task: session?.firstUserText ?? null,
taskSource: session?.firstUserText ? "first-user-message" : null,
task,
taskSource,
workspace,
workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null,
activeProject: repoRoot ? basename(repoRoot) : null,
workspaceSource,
activeProject,
activeProjectSource,
registered: reg
? { startedAt: reg.startedAt, updatedAt: reg.updatedAt, harness: reg.harness, pid: reg.pid, tmux: reg.tmux, layout: reg.layout, launchScript: reg.launchScript }
: null,
lastActivity,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,
@@ -317,10 +368,13 @@ function writeAtomic(path, data) {
}
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
export function scan(specs, { boardDir, isAlive, now } = {}) {
// seatsDir (optional): where `mosaic launch` registrations live; read only.
export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) {
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
const seen = loadSeen(boardDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen }));
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations) }));
for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -332,6 +386,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) {
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
seen: records.filter((r) => r.seen).map(seenKey),
registered: records.filter((r) => r.registered).map(seenKey),
registrationErrors,
sessions: records,
};
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
+3 -3
View File
@@ -67,7 +67,7 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage() }) {
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/seen") {
@@ -79,7 +79,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage()
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, scan(specs, { boardDir, isAlive, now }));
sendJson(res, 200, scan(specs, { boardDir, isAlive, now, seatsDir }));
} catch (err) {
sendJson(res, 500, { error: err.message });
}
@@ -97,7 +97,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage()
if (url.pathname === "/api/board") {
let index;
try {
index = scan(specs, { boardDir, isAlive, now });
index = scan(specs, { boardDir, isAlive, now, seatsDir });
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
+247
View File
@@ -10,6 +10,7 @@ import {
readdirSync,
existsSync,
statSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
@@ -31,7 +32,10 @@ import {
findRepoRoot,
loadSeen,
markSeen,
loadRegistrations,
matchRegistration,
} from "../src/scan.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
@@ -413,6 +417,249 @@ test("scan: the written record carries task, workspace and activeProject", () =>
assert.equal(onDisk.workspace, "/w");
});
// ---------------------------------------------------------------------------
// 4d. Registration (mosaic launch)
// ---------------------------------------------------------------------------
test("registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const registration = makeRegistration({
resolved: {
seat: "a",
project: "resolved-project",
sessionsDir,
seatDir: join(root, "seat-dir"),
launchScript: join(root, "seat-dir", "launch.sh"),
layout: "repo",
defaultWorkspace: "/resolved/workspace",
},
task: "registered task",
project: "registered-project",
workspace: "/registered/workspace",
harness: "pi",
tmux: { socket: null, session: "a" },
pid: 4242,
now: () => new Date("2026-09-12T13:00:00Z"),
});
const rec = scanAgent(spec, { isAlive: () => true, now: GATE_NOW, registration });
assert.equal(rec.task, "registered task");
assert.equal(rec.taskSource, "registration");
assert.equal(rec.activeProject, "registered-project");
assert.equal(rec.activeProjectSource, "registration");
assert.equal(rec.workspace, "/registered/workspace");
assert.equal(rec.workspaceSource, "registration");
assert.equal(rec.project, "p", "the grouping column (spec.project) is not touched by a registration");
assert.ok(rec.registered, "registered must be non-null once a matching registration is passed");
assert.equal(rec.registered.startedAt, registration.startedAt);
assert.equal(rec.registered.harness, "pi");
assert.equal(rec.registered.pid, 4242);
assert.deepEqual(rec.registered.tmux, { socket: null, session: "a" });
assert.equal(rec.registered.layout, "repo");
assert.equal(rec.registered.launchScript, join(root, "seat-dir", "launch.sh"));
});
test("registration: empty task and null project/workspace leave the derived values in place; registered is still non-null", () => {
const root = makeRoot();
const repo = join(root, "repo");
mkdirSync(join(repo, ".git"), { recursive: true });
const sessionsDir = join(repo, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: repo }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const registration = makeRegistration({
resolved: {
seat: "a",
project: null,
sessionsDir,
seatDir: repo,
launchScript: join(repo, "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "",
project: null,
workspace: null,
now: () => new Date("2026-09-12T13:00:00Z"),
});
const rec = scanAgent(spec, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW, registration });
assert.equal(rec.task, "derived task");
assert.equal(rec.taskSource, "first-user-message");
assert.equal(rec.activeProject, "repo");
assert.equal(rec.activeProjectSource, "workspace-git-root");
assert.equal(rec.workspace, repo);
assert.equal(rec.workspaceSource, "session-cwd");
assert.notEqual(rec.registered, null, "an empty task and null project/workspace still leave a registration attached");
});
test("registration: no registration leaves the Gate A fields exactly as before, and registered is null", () => {
const root = makeRoot();
const repo = join(root, "repo");
mkdirSync(join(repo, ".git"), { recursive: true });
const sessionsDir = join(repo, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: repo }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const withoutReg = scanAgent(spec, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(withoutReg.registered, null);
assert.equal(withoutReg.task, "derived task");
assert.equal(withoutReg.taskSource, "first-user-message");
assert.equal(withoutReg.activeProject, "repo");
assert.equal(withoutReg.activeProjectSource, "workspace-git-root");
assert.equal(withoutReg.workspace, repo);
assert.equal(withoutReg.workspaceSource, "session-cwd");
const plainRoot = makeRoot();
const plainSessions = join(plainRoot, "sessions");
writeSessionFile(plainSessions, "s.jsonl", [sessionLine({ id: "s2", timestamp: "2026-09-12T14:00:00Z", cwd: null })]);
const noWorkspace = scanAgent({ agent: "a", project: "p", sessionsDir: plainSessions, tmux: {} }, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(noWorkspace.registered, null);
assert.equal(noWorkspace.activeProjectSource, null, "no workspace at all means no repo root, so the source is null, not a guess");
});
test("loadRegistrations: a missing seatsDir gives empty lists", () => {
const root = makeRoot();
assert.deepEqual(loadRegistrations(join(root, "no-such-seats")), { registrations: [], errors: [] });
assert.deepEqual(loadRegistrations(null), { registrations: [], errors: [] });
});
test("loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored", () => {
const root = makeRoot();
const seatsDir = join(root, "seats");
const good = makeRegistration({
resolved: {
seat: "good",
project: "p",
sessionsDir: join(root, "sessions"),
seatDir: join(root, "good"),
launchScript: join(root, "good", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "do it",
});
writeRegistration(seatsDir, good);
mkdirSync(join(seatsDir, "repo", "broken"), { recursive: true });
writeFileSync(join(seatsDir, "repo", "broken", "registration.json"), "{ not json");
const unknownField = { ...good, seat: "weird", extraField: "surprise!!!" };
mkdirSync(join(seatsDir, "repo", "weird"), { recursive: true });
writeFileSync(join(seatsDir, "repo", "weird", "registration.json"), JSON.stringify(unknownField));
// A stray non-directory entry directly under seatsDir must be ignored, not
// treated as a broken seat.
writeFileSync(join(seatsDir, "not-a-seat.txt"), "stray file, not a seat directory");
writeFileSync(join(seatsDir, "repo", "not-a-seat.txt"), "stray file inside a layout directory");
// A record whose contents disagree with its path is an error, not a match.
mkdirSync(join(seatsDir, "fleet", "good"), { recursive: true });
writeFileSync(join(seatsDir, "fleet", "good", "registration.json"), JSON.stringify(good));
const { registrations, errors } = loadRegistrations(seatsDir);
assert.equal(registrations.length, 1);
assert.equal(registrations[0].seat, "good");
assert.equal(errors.length, 3);
assert.ok(errors.some((e) => e.startsWith("seat fleet/good:") && e.includes("does not match its path")), "a record under the wrong layout is reported");
assert.ok(errors.some((e) => e.startsWith("seat repo/broken:")), "the broken record's error names its seat");
assert.ok(errors.some((e) => e.startsWith("seat repo/weird:")), "the unknown-field record's error names its seat");
for (const e of errors) {
assert.ok(!e.includes("surprise!!!"), "an error must not echo a record's field values");
assert.ok(!e.includes("not json"), "an error must not echo the raw file contents");
}
});
test("matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing)", () => {
const root = makeRoot();
const sessionsDir = join(root, "repo-sessions");
mkdirSync(sessionsDir, { recursive: true });
const spec = { agent: "darkwing", project: "repo-project", sessionsDir, tmux: {} };
const reg = makeRegistration({
resolved: {
seat: "darkwing",
project: null,
sessionsDir,
seatDir: root,
launchScript: join(root, "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "",
});
assert.equal(matchRegistration(spec, [reg]), reg);
// The spec's sessionsDir differs textually from the registration's, but
// resolves (via realpath) to the same real directory.
const linkPath = join(root, "sessions-link");
symlinkSync(sessionsDir, linkPath);
const specViaLink = { ...spec, sessionsDir: linkPath };
assert.equal(matchRegistration(specViaLink, [reg]), reg);
// A registration with sessionsDir null (layout "unknown") never matches.
const regNoSessions = { ...reg, sessionsDir: null };
assert.equal(matchRegistration(spec, [regNoSessions]), null);
// Same seat name, different sessionsDir: the repo and fleet layouts can
// both have a "darkwing", and they are different seats.
const fleetSessions = join(root, "fleet-sessions");
mkdirSync(fleetSessions, { recursive: true });
const fleetReg = { ...reg, sessionsDir: fleetSessions };
assert.equal(matchRegistration(spec, [fleetReg]), null);
});
test("scan: writes the registration override to disk; index.json carries registered and registrationErrors", () => {
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: ["derived"] }),
]);
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: {
seat: "a",
project: "p",
sessionsDir,
seatDir: join(root, "a"),
launchScript: join(root, "a", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "registered task",
});
writeRegistration(seatsDir, reg);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const index = scan([spec], { boardDir, seatsDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.deepEqual(index.registered, ["p/a"]);
assert.deepEqual(index.registrationErrors, []);
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
assert.equal(onDisk.task, "registered task");
assert.equal(onDisk.taskSource, "registration");
assert.ok(onDisk.registered);
});
test("scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before", () => {
const root = makeRoot();
const boardDir = join(root, "board");
assert.throws(() => scan([], { boardDir, seatsDir: "relative/seats", isAlive: () => true, now: GATE_NOW }), ConfigError);
const index = scan([], { boardDir, isAlive: () => true, now: GATE_NOW });
assert.deepEqual(index.registered, []);
assert.deepEqual(index.registrationErrors, []);
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------
+93 -1
View File
@@ -14,6 +14,7 @@ import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError, markSeen } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
@@ -201,6 +202,54 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
}
});
test("startServer: a seatsDir registration overrides the row and index.registered reflects 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: "user", texts: ["derived task"] }),
]);
const boardDir = join(root, "board");
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: {
seat: "agent1",
project: "proj",
sessionsDir,
seatDir: join(root, "agent1"),
launchScript: join(root, "agent1", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "registered task",
});
writeRegistration(seatsDir, reg);
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs,
boardDir,
seatsDir,
isAlive: () => ({ alive: true, workspace: null }),
page: "<html></html>",
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/board`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.sessions[0].taskSource, "registration");
assert.equal(body.sessions[0].task, "registered task");
assert.deepEqual(body.registered, ["proj/agent1"]);
assert.deepEqual(body.registrationErrors, []);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 4. /api/board: scan failure surfaces as a 500 with an error field
// ---------------------------------------------------------------------------
@@ -518,7 +567,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
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);
assert.match(r.stdout, /1 seen, 0 registered\)\s*$/m);
});
// ---------------------------------------------------------------------------
@@ -588,3 +637,46 @@ test("page.html: every row shows Task and Active project, derived or the word un
assert.equal(heads.length, 3, "all three tables carry the two new headers");
assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
});
// ---------------------------------------------------------------------------
// 9. page.html: registration source tags and the Registered detail row
// ---------------------------------------------------------------------------
test("page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.ok(rowPair, "buildRowPair() must exist in page.html");
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
assert.match(
rowPair[0],
/<dt>Registered<\/dt><dd>" \+ registeredText\(rec\.registered\) \+ "<\/dd>/,
"the detail list has a Registered row built by registeredText()",
);
const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/);
assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
const sourceLabel = JSON.parse(sourceLabelMatch[1]);
assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
// sourceTag() and fromSource() are one-liners in page.html; match them by
// line rather than by a "function ... { ... \n }" block regex, which
// assumes a closing brace on its own line.
const lines = html.split("\n");
const sourceTagLine = lines.find((l) => l.includes("function sourceTag(source)"));
assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
const fromSourceLine = lines.find((l) => l.includes("function fromSource(source)"));
assert.ok(fromSourceLine, "fromSource() must exist in page.html");
assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
const registeredTextFn = html.match(/function registeredText\(reg\) \{[\s\S]*?\n \}/);
assert.ok(registeredTextFn, "registeredText() must exist in page.html");
for (const line of registeredTextFn[0].split("\n")) {
if (!/\breg\.[a-zA-Z]/.test(line)) continue;
assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
}
});