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:
@@ -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