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
+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()}`);
}
});