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:
@@ -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.",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>" +
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user