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