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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user