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