diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 1c70815e..244ad40b 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -2063,3 +2063,42 @@ a live pane, the rest from the log; every fleet seat reads `.mosaic` as its active project because `~/.mosaic` is a git checkout and the roster names no project. Missing launcher signals are listed in the plan page Step 3 log. Sonnet review: APPROVED, no findings. + +## 2026-09-12 — Seat registration: `mosaic launch ` (#1504) + +Before: the board derived task, project and workspace from the session log +and tmux, and had no way to know what a seat was actually told to do. +Launch scripts left no record. + +After: `scripts/mosaic launch [--task ..] [-- args]` +(package `packages/seat`) writes +`/seats///registration.json` (seat, project, task, +workspace, tmux, harness, startedAt, pid, plus sessionsDir, seatDir, +launchScript, layout, updatedAt, version) and then replaces itself with the +seat's launch.sh via `process.execve`, so pid, terminal, exit code and the +tmux foreground command are the script's own. `scripts/mosaic seat task + ` edits the task only. The board reads every registration on +each scan, matches by sessions directory, and lets a registered task, +project or workspace override the derived value with `taskSource` / +`activeProjectSource` / `workspaceSource` = `registration`; the page shows a +"registered" tag and a Registered detail line. The four repository launch +scripts re-enter through the launcher unless `MOSAIC_LAUNCH_REGISTERED` is +set or `--check` is passed; fleet launchers are untouched, their one-line +change is on the plan page for orch-01. + +Verification: seat suite 15/15, control-board 89/89 (was 79), launch-script +suites 5/5 (each real launch.sh in a fixture that is its own data root), +registry 69/69, config 24/24. Live: the real `agents/darkwing/launch.sh` +through the launcher against a temporary data root, then a scratch board on +7332 reading it; the darkwing row showed the registered task, both source +tags and the Registered line, and a `seat task` change appeared with its +update time. + +Corrections: sonnet review (first pass) found the record path keyed by seat +name alone, which would have made the repo and fleet "darkwing" overwrite +each other; fixed by keying on layout and seat, `seat task` refuses an +ambiguous bare name unless `--layout` is given; second pass APPROVED. Found +on the way: Jason's own launch refused on the Pi pin because +`node_modules` still held 0.84.4 after commit 557aba0f moved the pin to +0.85.1; `npm ci` fixed it and the refusal message now names both versions. +The pin is canon and stays unless Jason decides otherwise. diff --git a/agents/darkwing/launch.sh b/agents/darkwing/launch.sh index 825a792e..8b3d5c55 100755 --- a/agents/darkwing/launch.sh +++ b/agents/darkwing/launch.sh @@ -2,4 +2,9 @@ # Darkwing's native development mode through the Mosaic agent entry point. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Register this seat with the control board (packages/seat) unless already +# registered by `mosaic launch` or only running the checks. +if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then + exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi darkwing -- "$@" +fi exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@" diff --git a/agents/dewey/launch.sh b/agents/dewey/launch.sh index 5210a4a5..c3ebe154 100755 --- a/agents/dewey/launch.sh +++ b/agents/dewey/launch.sh @@ -2,4 +2,9 @@ # Dewey's native development mode through the Mosaic agent entry point. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Register this seat with the control board (packages/seat) unless already +# registered by `mosaic launch` or only running the checks. +if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then + exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi dewey -- "$@" +fi exec "$REPO/scripts/agent.sh" --host-dev dewey "$@" diff --git a/agents/filbert/launch.sh b/agents/filbert/launch.sh index b31d22c6..e6b24e77 100755 --- a/agents/filbert/launch.sh +++ b/agents/filbert/launch.sh @@ -2,5 +2,10 @@ # Filbert's native development mode through the Mosaic agent entry point. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Register this seat with the control board (packages/seat) unless already +# registered by `mosaic launch` or only running the checks. +if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then + exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi filbert -- "$@" +fi exec "$REPO/scripts/agent.sh" --host-dev filbert "$@" \ --provider openai-codex --model gpt-6-astra --thinking low diff --git a/agents/rocko/launch.sh b/agents/rocko/launch.sh index 4e791ba8..2dd56a66 100755 --- a/agents/rocko/launch.sh +++ b/agents/rocko/launch.sh @@ -2,6 +2,11 @@ # Rocko's native Claude Code development session. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Register this seat with the control board (packages/seat) unless already +# registered by `mosaic launch` or only running the checks. +if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then + exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness claude-code rocko -- "$@" +fi cd "$REPO" fail() { echo "rocko: $*" >&2; exit 1; } usage() { diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index d0efbf10..0bf21f58 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -220,3 +220,4 @@ are never rewritten or removed; corrections are new entries. - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, fourth refinement (#1503): Jason asked for "N of N" in the project header; now "shown of total" while a checkbox hides rows, plain total otherwise. Page-only change plus one static test (66/66); sonnet review APPROVED (no findings); live check in Chrome at all four edges. - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded). - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, Gate A fix (#1503), asked by the professor session on Jason's behalf: per-row Task (first user message), Active project (nearest git checkout above the workspace) and Workspace (live tmux pane path, else session cwd), "unknown" when not derivable; missing launcher signals logged on the plan page. Fixture test per field (79/79); live check on a scratch server; sonnet review APPROVED (no findings). +- 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch ` registers then execs the launch script unchanged; `seat task` edits the task); board reads `/seats///registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8c7d8222..af4d72df 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -130,6 +130,23 @@ Push is never automatic. | `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases | | `scripts/gitea-api.sh [body]` | Gitea API helper | Token never on argv/stdout | +## Seat launch with registration (`scripts/mosaic`) + +```bash +scripts/mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...] +scripts/mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +``` + +Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and +leaves one registration record at `/seats///registration.json` +that the control board reads: seat, project, task, workspace, tmux session, +harness, start time, pid. Rewritten on every launch. `seat task` changes the +task field only. The four repository seats (`agents/*/launch.sh`) register +themselves through this path unless called with `--check`. Not the +npm-global `mosaic` CLI; run by path. Exit codes: the launch script's own +once it runs; before that 1 could not start, 2 invalid config or seat, 4 +usage. Details and the record's fields: `packages/seat/README.md`. + ## Tools (host-side) Host-side helpers under `tools/`, outside the `scripts/` command surface. diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md index 0de75a8d..3fd28252 100644 --- a/docs/plans/2026-09-12_control-board-mvp.md +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -114,6 +114,10 @@ Two kinds of agents are scanned today: - One file per agent: `/board/sessions//.json` - One summary file for the whole board: `/board/index.json` +- One registration per seat started through `mosaic launch`: + `/seats///registration.json` (#1504). Written by the + launcher, read by the board, rewritten on relaunch. Not under `board/` + because the board never writes it. - `dataRoot` comes from `~/.config/mosaic-dev/config.json`. If that config is missing or broken, the scanner refuses to run. It does not guess a fallback location. @@ -144,12 +148,18 @@ node --test packages/control-board/tests/ ## Boundaries -- No changes to any launcher script. +- No changes to any launcher script. Corrected 2026-09-12 by Jason's go on + the `mosaic launch` brief (#1504): the four repository launch scripts + (`agents/{darkwing,dewey,filbert,rocko}/launch.sh`) gained a guard that + re-enters them through `scripts/mosaic launch` for registration. The + fleet launchers under `~/.mosaic` are still untouched; their one-line + change is written in the Step 3 log for orch-01 to apply. - No new files at the repository root. - No secrets read, stored, or printed. - No daemon yet. In step 1 the scanner is run by hand. In step 2 the page may trigger it on refresh; nothing runs it on a schedule. -- No changes to `packages/mosaic`. +- No changes to `packages/mosaic`. Still true: seat registration went into + a new package, `packages/seat`, not the hardened registry. - This plan does not authorize push or merge beyond whatever the existing refactor-branch plan already allows. @@ -210,3 +220,73 @@ used" hold above: the selection is made, on evidence from today's use. It does not start WebUI build work. Sequence stays: seat registration via `mosaic launch` (in progress), then reply-from-board, then the WebUI on the Console design absorbs the board as its first screen. + +**2026-09-12 — Seat registration: `mosaic launch ` (#1504).** Gate A +passed on Jason's go, relayed by the professor session with a brief. New +package `packages/seat` and wrapper `scripts/mosaic`: + +- `scripts/mosaic launch [--task ..] [-- args]` resolves + `agents//launch.sh` (repo layout) or a seat directory (fleet layout, + `/.pi`), writes `/seats///registration.json`, then + replaces itself with the launch script (`process.execve`), so the pid, the + terminal, the exit code and the tmux foreground command are the script's + own. The record: seat, project, task, workspace, tmux, harness, startedAt, + pid, plus sessionsDir (the board matches on it, not on the name, because + "darkwing" exists in both layouts), seatDir, launchScript, layout, + updatedAt, version. Rewritten on every launch. +- `scripts/mosaic seat task ` changes the task only; refuses + when the seat was never launched this way. +- Scanner: registrations are read on every scan; a registered task, project + or workspace wins and `taskSource`, `activeProjectSource`, + `workspaceSource` read `registration`; empty or null registered fields + leave the derived value; rows without a registration are unchanged. A + malformed record is listed in `registrationErrors` and skipped. Page: a + small "registered" tag next to the value and a Registered line in the + detail (start, harness, pid, tmux, layout). +- The four repository launch scripts (`agents/{darkwing,dewey,filbert, + rocko}/launch.sh`) gained a five-line guard: unless + `MOSAIC_LAUNCH_REGISTERED` is set or `--check` is among the arguments, + re-enter through `scripts/mosaic launch --harness pi|claude-code`. So the + plain `agents/darkwing/launch.sh` registers too. rocko registers but is + still not on the board: it has no pi session log to scan. Gap, not a bug. +- Fleet launchers under `~/.mosaic` are not edited from this repository. + One-line change for orch-01 to apply in + `~/.mosaic/fleet/bin/launch-seat.sh`, after `SEAT_DIR=` is set (the + per-seat `launch.sh` files are shims and need nothing): + `[[ -n "${MOSAIC_LAUNCH_REGISTERED:-}" || "$CHECK" -eq 1 ]] || exec /mnt/storage/src/mosaic-stack/scripts/mosaic launch --harness pi "$SEAT_DIR" -- "$@"` + Fleet records carry project and workspace null (roster has no project + field; all fleet seats run in `~/.mosaic`), so those rows keep their + derived values until `--project`/`--workspace` are passed or the roster + gains a field. +- Sonnet review found one defect before commit: the record path was keyed + by seat name alone, so the repo and fleet "darkwing" would have overwritten + each other. Fixed by keying on layout and seat + (`seats///`); `seat task` refuses a bare name registered in + more than one layout unless `--layout` is given. Second review pass + approved. The reviewer also noted `process.execve` is marked experimental + in Node; recorded, accepted (Node 24+ is the floor and the API is the only + way to keep the seat's pid). +- Name clash noted: the npm-global `mosaic` CLI (estate tooling) has no + `launch` or `seat` command; the repository wrapper is run by path. + +Tests: seat package 15 (record write via a fake launch.sh, args and exit +passthrough, relaunch rewrite, `--task`, tmux null, tmuxContext with a fake +exec, task update and its refusals, malformed records, samePath through a +symlink, recursion guard); control board 79 → 89 (override precedence, +empty registration, no registration parity, loadRegistrations errors, +matchRegistration by realpath and the same-name-different-layout case, scan +on disk, server round trip, page source checks); the two launch-script +suites now run each real `launch.sh` in a fixture that is its own data root +and assert the record and the `MOSAIC_LAUNCH_REGISTERED` value Pi/Claude +saw (5/5); registry 69/69; config 24/24. Live check: the real +`agents/darkwing/launch.sh` run through the launcher against a temporary +data root, then a scratch board on 7332 reading that root: the darkwing +row shows the registered task, "registered" on task and project, and the +Registered detail line. + +Found on the way: Jason's own launch refused with "local Pi must match +package.json (0.85.1)". Commit 557aba0f (#1499) moved the pin without an +`npm ci` in this checkout (node_modules held 0.84.4; the global pi was +already 0.85.1, which hid it). Fixed by `npm ci`; the refusal now names +both versions. Jason questioned the pin itself; it is canon (AGENTS.md +"Version pin") and stays unless he decides otherwise. diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index fd2f741f..c03badb8 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -22,13 +22,27 @@ First step-3 refinement landed 2026-09-12: liveness follows the tmux pane "Waiting on you" until the agent writes again (`docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md`). -Next action: step 3 of #1503 — daily use. Jason opens the page while he works -and reports what is wrong, missing or confusing; each report becomes a small -fix on this package with a test, committed and pushed to `refactor`. Close -#1503 when Jason says the page answers "who is waiting on me" without him -opening a terminal. Out of scope until he asks: auth, hooks/plugins, comms, -memory, launcher changes, non-Pi harnesses, new root files. The registry line -(increment 3, headless identity-env leak) stays parked; #1500 is closed. +Gate A (task, active project, workspace per row) passed on 2026-09-12 and +Jason's go on the professor session's brief opened #1504: seat registration. +`scripts/mosaic launch ` runs a seat's launch script unchanged and +leaves one record at `/seats///registration.json` that the +board reads instead of guessing; `scripts/mosaic seat task ` +changes the task. The four repository launch scripts register themselves. +Package: `packages/seat`. Fleet launchers under `~/.mosaic` are not edited +from this repository; the one-line change for them is in the plan page. + +Next action: step 3 of #1503 continues — daily use with #1504 landed. Jason +launches his seats through the new path (or the launch scripts, which now +register), sets tasks with `seat task`, and reports what is wrong, missing +or confusing; each report becomes a small fix with a test, committed and +pushed to `refactor`. The sequence Jason set stays: seat registration (done), +then reply-from-board, then the WebUI on Dewey's Console design absorbs the +board as its first screen. Close #1503 when Jason says the page answers "who +is waiting on me" without him opening a terminal. Out of scope until he +asks: auth or provider registry, roster schema changes, hooks/plugins, comms, +memory, multiple sessions per seat, stopping seats, new root files. The +registry line (increment 3, headless identity-env leak) stays parked; #1500 +is closed. ## Completed checkpoint: #1500 increment 2 (historical) diff --git a/packages/control-board/README.md b/packages/control-board/README.md index cb72c305..b89832d1 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -82,7 +82,7 @@ node --test packages/control-board/tests/ ``` /board/ - index.json # summary: counts, waiting-on-you list, seen list, all records + index.json # summary: counts, waiting-on-you, seen, registered, registrationErrors, all records seen.json # Jason's "seen" marks (see below) sessions/ / @@ -95,8 +95,9 @@ only when Jason clicks "Seen" or "Unsee" on the page (via `POST the agent's newest message still has that exact `lastActivity` timestamp — as soon as the agent writes anything new, `lastActivity` changes, the mark no longer matches, and the row falls back into "Waiting on you" on its own. -Every row also shows three "what and where" fields, each derived from the -session log and tmux or shown as the word "unknown", never guessed: +Every row also shows three "what and where" fields, each taken from the +seat's registration when it has one, else derived from the session log and +tmux, else shown as the word "unknown", never guessed: - **Task** — the session's first user message (collapsed to one line, 240 characters). pi logs carry no task envelope, so this is the only @@ -112,6 +113,20 @@ session log and tmux or shown as the word "unknown", never guessed: tmux could not be asked, the `cwd` from the session log. The record says which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`). +A seat started through `scripts/mosaic launch ` (see +`packages/seat/README.md`, #1504) has a registration at +`/seats///registration.json`. The scan reads every one of +those and matches a record to a row by its sessions directory, never by +name alone. A registered task, project or workspace replaces the derived +value and the row's source field (`taskSource`, `activeProjectSource`, +`workspaceSource`) reads `registration`; the page shows a small source tag +next to the value and a "Registered" line in the detail with the start time, +harness, pid and tmux session. An empty task or a null project or workspace +in the record leaves the derived value in place. Rows with no registration +are exactly as before. The board only reads `seats/`; `mosaic launch` and +`mosaic seat task` are the only writers. A malformed record is listed in +`registrationErrors` on the index (and on stderr for `scan`) and skipped. + Marked rows are listed under a collapsed "Seen (N)" section on the page, each with an "Unsee" button, so nothing marked is ever out of reach. Each project table has "Hide offline" and "Hide seen" checkboxes (both on by @@ -140,6 +155,8 @@ values), the scan refuses rather than silently dropping every mark. "workspace": "/mnt/storage/src/mosaic-stack", "workspaceSource": "tmux-pane", "activeProject": "mosaic-stack", + "activeProjectSource": "workspace-git-root", + "registered": null, "lastActivity": "2026-09-12T15:04:33.000Z", "ageSeconds": 42, "lastAssistantText": "Ready for the next step whenever you are.", diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index 23c3a8b8..6b2bd8bc 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -40,8 +40,9 @@ async function main() { const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))]; const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true; const boardDir = join(dataRoot, "board"); + const seatsDir = join(dataRoot, "seats"); if (opts.command === "serve") { - const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive }); + const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir }); const addr = server.address(); process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`); const stop = () => server.close(() => process.exit(0)); @@ -49,7 +50,8 @@ async function main() { process.on("SIGTERM", stop); return; } - const index = scan(specs, { boardDir, isAlive }); + const index = scan(specs, { boardDir, isAlive, seatsDir }); + for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`); if (opts.print) { for (const s of index.sessions) { const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " "; @@ -57,7 +59,7 @@ async function main() { process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`); } } - process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen)\n`); + process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered)\n`); } main().catch((err) => { diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html index 620b689d..bd30777d 100644 --- a/packages/control-board/src/page.html +++ b/packages/control-board/src/page.html @@ -65,6 +65,7 @@ .msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .unknown{color:var(--muted);font-style:italic} + .source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap} .detail-row td{background:var(--raised)} .detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)} .detail-list dt{color:var(--muted);font-family:var(--font)} @@ -184,6 +185,21 @@ return ''; } + var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" }; + function sourceLabel(source) { return SOURCE_LABEL[source] || source; } + function sourceTag(source) { return source ? '' + esc(sourceLabel(source)) + "" : ""; } + function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; } + function registeredText(reg) { + if (!reg) return "no (not started through mosaic launch)"; + var parts = ["started " + esc(reg.startedAt || "—")]; + if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt)); + if (reg.harness) parts.push("harness " + esc(reg.harness)); + if (reg.pid) parts.push("pid " + esc(reg.pid)); + if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : "")); + if (reg.layout) parts.push(esc(reg.layout) + " layout"); + return parts.join("; "); + } + function buildRowPair(rec, showProject) { var idx = rowIdx++; var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/"); @@ -193,12 +209,14 @@ ? '' + esc(rec.lastError) + "" : '' + esc(rec.lastAssistantText || "—") + ""; var projectCell = showProject ? "" + esc(rec.project) + "" : ""; - // Gate A fields: derived from the log and tmux, or "unknown". Never guessed. + // Gate A fields: from the seat's registration (mosaic launch), else derived + // from the log and tmux, else "unknown". Never guessed. The source tag on + // the row says which one is shown. var task = rec.task - ? '' + esc(rec.task) + "" + ? '' + esc(rec.task) + "" + sourceTag(rec.taskSource) : 'unknown'; var activeProject = rec.activeProject - ? '' + esc(rec.activeProject) + "" + ? '' + esc(rec.activeProject) + "" + sourceTag(rec.activeProjectSource) : 'unknown'; var main = '' + @@ -219,9 +237,10 @@ '
' + "
Session ID
" + esc(rec.sessionId || "—") + "
" + "
Session file
" + esc(rec.sessionFile || "—") + "
" + - "
Task
" + (rec.task ? esc(rec.task) : "unknown") + "
" + - "
Active project
" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "
" + - "
Workspace
" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "
" + + "
Task
" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "
" + + "
Active project
" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "
" + + "
Workspace
" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "
" + + "
Registered
" + registeredText(rec.registered) + "
" + "
Session cwd
" + esc(rec.cwd || "—") + "
" + "
Tmux session
" + tmux + "
" + "
Last activity
" + esc(rec.lastActivity || "—") + "
" + diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index 406aff8f..fca9ffba 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy import { join, basename, isAbsolute, resolve } from "node:path"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; +import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs"; export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]); const TEXT_LIMIT = 240; @@ -243,15 +244,52 @@ function liveness(result) { return { alive: result ?? null, workspace: null }; } +// Registrations written by `mosaic launch ` (packages/seat): one +// record per seat under /seats///registration.json. Returns +// the readable records plus one error line per unreadable one; a bad record +// must not take the whole board down, but it is not silently dropped either. +export function loadRegistrations(seatsDir) { + const registrations = []; + const errors = []; + if (!seatsDir || !existsSync(seatsDir)) return { registrations, errors }; + for (const layout of LAYOUTS) { + const layoutDir = join(seatsDir, layout); + if (!existsSync(layoutDir) || !statSync(layoutDir).isDirectory()) continue; + for (const seat of readdirSync(layoutDir).sort()) { + if (!statSync(join(layoutDir, seat)).isDirectory()) continue; + try { + const rec = readRegistration(seatsDir, seat, layout); + if (rec) registrations.push(rec); + } catch (err) { + if (!(err instanceof SeatError)) throw err; + errors.push(`seat ${layout}/${seat}: ${err.message}`); + } + } + } + return { registrations, errors }; +} + +// A registration belongs to a row when it names the same sessions directory. +// Seat names alone are not enough: the repo and fleet layouts both have a +// "darkwing", and they are different seats. +export function matchRegistration(spec, registrations) { + return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null; +} + // One agent -> one status record. // // Three fields answer "what is this seat doing, and where" (Gate A ask, -// 2026-09-12). Each is derived, never guessed; null means "unknown". -// task the session's first user message. The log has no task -// envelope entry, so this is the only assignment signal it holds. -// workspace the live pane path from tmux, else the session log's cwd. -// activeProject basename of the nearest git repo root above the workspace. -export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) { +// 2026-09-12). Each is derived, never guessed; null means "unknown". A seat +// started through `mosaic launch` has a registration, and a registered task, +// project or workspace wins over the derived value; the *Source field says +// which one the row shows. +// task registration.task, else the session's first user message +// (the log has no task envelope entry). +// workspace registration.workspace, else the live pane path from tmux, +// else the session log's cwd. +// activeProject registration.project, else the basename of the nearest git +// repo root above the workspace. +export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null } = {}) { const live = liveness(isAlive(spec.tmux)); const alive = live.alive; const file = findNewestSession(spec.sessionsDir); @@ -263,8 +301,17 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), const needsYou = state === "waiting" || state === "error"; const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity; const cwd = session?.cwd ?? null; - const workspace = live.workspace ?? cwd; - const repoRoot = workspace ? findRepoRoot(workspace) : null; + const reg = registration && typeof registration === "object" ? registration : null; + const derivedWorkspace = live.workspace ?? cwd; + const workspace = reg?.workspace ?? derivedWorkspace; + const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null; + const repoRoot = derivedWorkspace ? findRepoRoot(derivedWorkspace) : null; + const derivedProject = repoRoot ? basename(repoRoot) : null; + const activeProject = reg?.project ?? derivedProject; + const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null; + const firstUserText = session?.firstUserText ?? null; + const task = reg?.task ? reg.task : firstUserText; + const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null; return { agent: spec.agent, project: spec.project, @@ -276,11 +323,15 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), sessionFile: file, sessionId: session?.sessionId ?? null, cwd, - task: session?.firstUserText ?? null, - taskSource: session?.firstUserText ? "first-user-message" : null, + task, + taskSource, workspace, - workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null, - activeProject: repoRoot ? basename(repoRoot) : null, + workspaceSource, + activeProject, + activeProjectSource, + registered: reg + ? { startedAt: reg.startedAt, updatedAt: reg.updatedAt, harness: reg.harness, pid: reg.pid, tmux: reg.tmux, layout: reg.layout, launchScript: reg.launchScript } + : null, lastActivity, ageSeconds, lastAssistantText: session?.lastAssistantText ?? null, @@ -317,10 +368,13 @@ function writeAtomic(path, data) { } // Scan every spec and write /sessions//.json plus index.json. -export function scan(specs, { boardDir, isAlive, now } = {}) { +// seatsDir (optional): where `mosaic launch` registrations live; read only. +export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) { if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path"); + if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null"); const seen = loadSeen(boardDir); - const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen })); + const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir); + const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations) })); for (const rec of records) { const dir = join(boardDir, "sessions", rec.project); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -332,6 +386,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) { counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])), waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey), seen: records.filter((r) => r.seen).map(seenKey), + registered: records.filter((r) => r.registered).map(seenKey), + registrationErrors, sessions: records, }; mkdirSync(boardDir, { recursive: true, mode: 0o700 }); diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 4d09f936..e23b031f 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -67,7 +67,7 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) { } // specs: agent specs to scan on each request. boardDir: where scan writes. -export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) { +export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage() }) { return createHttpServer((req, res) => { const url = new URL(req.url, "http://localhost"); if (req.method === "POST" && url.pathname === "/api/seen") { @@ -79,7 +79,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage() return sendJson(res, 400, { error: err.message }); } try { - sendJson(res, 200, scan(specs, { boardDir, isAlive, now })); + sendJson(res, 200, scan(specs, { boardDir, isAlive, now, seatsDir })); } catch (err) { sendJson(res, 500, { error: err.message }); } @@ -97,7 +97,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage() if (url.pathname === "/api/board") { let index; try { - index = scan(specs, { boardDir, isAlive, now }); + index = scan(specs, { boardDir, isAlive, now, seatsDir }); } catch (err) { res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify({ error: err.message }) + "\n"); diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs index 55dbf605..aedd224f 100644 --- a/packages/control-board/tests/scan.test.mjs +++ b/packages/control-board/tests/scan.test.mjs @@ -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 // --------------------------------------------------------------------------- diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 91f532b9..c15813e7 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -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: "", + }); + 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, /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], + /
Registered<\/dt>
" \+ 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()}`); + } +}); diff --git a/packages/seat/README.md b/packages/seat/README.md new file mode 100644 index 00000000..d378aa5b --- /dev/null +++ b/packages/seat/README.md @@ -0,0 +1,138 @@ +# seat + +`mosaic launch ` starts a seat through its existing launch script, +unchanged, and leaves one registration record that the control board reads +instead of guessing. `mosaic seat task ` changes the task on +that record. Nothing else. No provider or auth registry, no roster schema +change, no stopping or killing of seats, one record per seat. + +Issue #1504. Plain ESM, no dependencies, Node 24 or newer. + +## Commands + +The repository wrapper is `scripts/mosaic`. It is not on PATH and is not +the npm-global `mosaic` CLI from the estate tooling, which has no `launch` +or `seat` command; run it by path. + +``` +scripts/mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] + [--harness NAME] [--repo PATH] [--config PATH] [-- args...] +scripts/mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +``` + +- `` is a name under `/agents/` (`--repo` defaults to the + current directory) or a path to a seat directory such as + `~/.mosaic/fleet/agents/orch-01`. Either way the directory must hold an + executable `launch.sh`. +- Everything after `--` goes to the launch script untouched. +- `launch` writes the registration, then replaces itself with the launch + script (`process.execve`). The seat keeps the same pid, terminal and + process group as if you had run `launch.sh` yourself, the launch script's + exit code is yours, and the tmux pane's foreground command stays the + harness, which the control board's liveness check depends on. Node marks + `process.execve` experimental (present since 24); it is the only way to + keep the seat's pid, so the package accepts that and pins Node 24 or + newer. The seat tests exercise it directly (exit passthrough). +- The launch script receives `MOSAIC_LAUNCH_REGISTERED=`. A + launch script that sees this variable is already registered and must not + call `mosaic launch` again; `mosaic launch` refuses to run when it is set. + +The four repository seats (`agents/darkwing`, `agents/dewey`, +`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh` +re-enters through `scripts/mosaic launch` unless already registered or +called with `--check`. So `agents/darkwing/launch.sh` and +`scripts/mosaic launch darkwing --task "..."` are the same path; the second +form is how you attach a task. A `--check` run never writes a record. + +## The record + +`/seats///registration.json`, directory 0700, file 0600, +written atomically. `layout` is `repo`, `fleet` or `unknown` (see below); +it is part of the path because a seat name alone is not unique, and +`seat task` refuses a bare name that is registered in more than one layout +until `--layout` says which. `dataRoot` comes from `~/.config/mosaic-dev/config.json` +(or `$MOSAIC_CONFIG`); a missing or unreadable config refuses the launch. + +The record is rewritten on every launch. It is written before the launch +script runs, so a launch the script itself refuses (a failed `--check`-style +precondition, a missing context file) still leaves a record with a pid that +is no longer running; the next launch replaces it. It is a launch record, +not a run record: it is not evidence, it holds one seat's latest launch only, and it +lives outside `/board/` because the board never writes here and a +scan never changes it. + +```json +{ + "version": 1, + "seat": "darkwing", + "project": "mosaic-stack", + "task": "Control board: seat registration (#1504)", + "workspace": "/mnt/storage/src/mosaic-stack", + "tmux": { "socket": null, "session": "darkwing" }, + "harness": "pi", + "startedAt": "2026-09-12T16:20:11.000Z", + "pid": 431734, + "sessionsDir": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions", + "seatDir": "/mnt/storage/src/mosaic-stack/agents/darkwing", + "launchScript": "/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh", + "layout": "repo", + "updatedAt": null +} +``` + +Fields asked for in the brief: `seat`, `project`, `task` (empty unless +`--task` or a later `seat task`), `workspace`, `tmux` (session name and +socket, from the `TMUX` variable of the pane the launch ran in; null outside +tmux), `harness` (only what `--harness` says; the repository launch scripts +pass `pi` or `claude-code`), `startedAt`, `pid`. + +Fields added, and why: + +- `sessionsDir`: how the board matches a record to a row. Seat names are not + unique across layouts (there is a `darkwing` in this repository and a + `darkwing` in the fleet), so the record names the sessions directory the + board already scans, and only an exact match counts. +- `seatDir`, `launchScript`, `layout`: what was launched and how the paths + were derived, so a wrong record can be traced without re-running anything. + `layout` is `repo` (`/agents/` with `/.git`), `fleet` + (`/.pi` exists) or `unknown` (nothing derived, `sessionsDir` null, + the record is still written but the board cannot match it). +- `updatedAt`: set only by `seat task`, so a task change is distinguishable + from a relaunch. +- `version`: so a later shape change can be refused rather than misread. + +`project` and `workspace` are derived only for the repo layout (the +repository's basename and root). For the fleet layout they are null unless +`--project` and `--workspace` are given, because the roster has no project +field and all fleet seats run in `~/.mosaic`; the board then keeps its own +derived values for those rows. + +## How the board uses it + +`packages/control-board` reads every `/seats///registration.json` +on each scan. A registered task, project or workspace wins over the derived +value, and the row's `taskSource`, `activeProjectSource` or +`workspaceSource` says `registration`. An empty task or a null project or +workspace in the record leaves the derived value in place. Rows without a +registration are unchanged. A malformed record is reported in +`registrationErrors` on the index and skipped; it never takes the board down +and it is never treated as absent silently. + +## Exit codes + +`launch` exits with the launch script's own code once the script runs. +Before that: 1 the launch script could not be started (the record is +removed again), 2 invalid data or configuration (missing config, no such +seat, no executable `launch.sh`, bad record on disk), 4 usage. +`seat task`: 0 ok, 1 no registration for that seat, 2 as above, 4 usage. + +## Tests + +``` +node --test packages/seat/tests/ +``` + +The repository launch scripts are covered end to end by +`scripts/test-darkwing-launch.mjs` (darkwing, dewey, filbert) and +`scripts/test-rocko-launch.mjs`, which run each `launch.sh` in a fixture +root that is also its own data root, so no registration reaches a real one. diff --git a/packages/seat/package.json b/packages/seat/package.json new file mode 100644 index 00000000..9485267b --- /dev/null +++ b/packages/seat/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mosaic/seat", + "version": "0.1.0", + "private": true, + "description": "Seat launch and registration: `mosaic launch ` runs a seat's launch script unchanged and leaves one registration record the control board reads.", + "license": "UNLICENSED", + "type": "module", + "engines": { "node": ">=24" }, + "bin": { "mosaic": "src/cli.mjs" }, + "exports": { ".": "./src/seat.mjs" }, + "scripts": { "test": "node --test tests/" } +} diff --git a/packages/seat/src/cli.mjs b/packages/seat/src/cli.mjs new file mode 100755 index 00000000..819d100d --- /dev/null +++ b/packages/seat/src/cli.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Usage: +// mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] +// [--harness NAME] [--repo PATH] [--config PATH] [-- args...] +// mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +// +// `launch` writes /seats//registration.json, then replaces +// itself with the seat's launch.sh, unchanged, with everything after `--` as +// its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch +// script that sees that variable is already registered and must not call +// mosaic launch again. The exit code is the launch script's own. +// +// Exit codes: the launch script's code (launch); 0 ok; 1 operation failed; +// 2 invalid data or configuration; 4 usage. +import { rmSync } from "node:fs"; +import { + SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext, + makeRegistration, writeRegistration, updateTask, +} from "./seat.mjs"; + +const USAGE = [ + "usage: mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]", + " mosaic seat task [--layout repo|fleet|unknown] [--config PATH]", +].join("\n"); + +export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED"; + +function parseLaunch(argv) { + const opts = { seat: null, task: "", project: undefined, workspace: undefined, harness: null, repo: process.cwd(), config: defaultConfigPath(), args: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => { + if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4); + return argv[++i]; + }; + if (a === "--") { opts.args = argv.slice(i + 1); break; } + else if (a === "--task") opts.task = next(); + else if (a === "--project") opts.project = next(); + else if (a === "--workspace") opts.workspace = next(); + else if (a === "--harness") opts.harness = next(); + else if (a === "--repo") opts.repo = next(); + else if (a === "--config") opts.config = next(); + else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4); + else if (opts.seat === null) opts.seat = a; + else throw new SeatError(`unexpected argument: ${a} (put launch script arguments after --)`, 4); + } + if (opts.seat === null) throw new SeatError(USAGE, 4); + return opts; +} + +function parseSeatTask(argv) { + const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--config" || a === "--layout") { + if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4); + opts[a.slice(2)] = argv[++i]; + } else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4); + else if (opts.seat === null) opts.seat = a; + else if (opts.task === null) opts.task = a; + else throw new SeatError(`unexpected argument: ${a}`, 4); + } + if (opts.seat === null || opts.task === null) throw new SeatError(USAGE, 4); + return opts; +} + +function launch(argv) { + const opts = parseLaunch(argv); + const resolved = resolveSeat(opts.seat, { repo: opts.repo }); + const seats = seatsDir(loadDataRoot(opts.config)); + // Register first, then replace this process with the launch script (same + // pid, same terminal, same process group). The launch script's exit code + // and signals are then the shell's to see directly, and the tmux pane's + // foreground command stays the harness itself, which the control board's + // liveness check depends on. + const record = makeRegistration({ + resolved, task: opts.task, project: opts.project, workspace: opts.workspace, + harness: opts.harness, tmux: tmuxContext(), pid: process.pid, + }); + const path = writeRegistration(seats, record); + process.stderr.write(`mosaic launch: registered ${resolved.seat} (${resolved.layout} layout) at ${path}\n`); + try { + process.execve(resolved.launchScript, [resolved.launchScript, ...opts.args], { ...process.env, [REGISTERED_ENV]: path }); + } catch (err) { + rmSync(path, { force: true }); + throw new SeatError(`could not run ${resolved.launchScript}: ${err.message}`, 1); + } + return 0; +} + +function seatTask(argv) { + const opts = parseSeatTask(argv); + const seats = seatsDir(loadDataRoot(opts.config)); + const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout }); + process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`); + return 0; +} + +function main(argv) { + if (process.env[REGISTERED_ENV] && argv[0] === "launch") { + throw new SeatError(`${REGISTERED_ENV} is already set; refusing to register a seat from inside a registered launch`, 4); + } + if (argv[0] === "launch") return launch(argv.slice(1)); + if (argv[0] === "seat" && argv[1] === "task") return seatTask(argv.slice(2)); + throw new SeatError(USAGE, 4); +} + +try { + process.exitCode = main(process.argv.slice(2)); +} catch (err) { + if (!(err instanceof SeatError)) throw err; + process.stderr.write(`refused: ${err.message}\n`); + process.exitCode = err.exitCode; +} diff --git a/packages/seat/src/seat.mjs b/packages/seat/src/seat.mjs new file mode 100644 index 00000000..ec0d0a96 --- /dev/null +++ b/packages/seat/src/seat.mjs @@ -0,0 +1,236 @@ +// Seat registration: one small record per seat under /seats/// +// registration.json, written by `mosaic launch ` and read by the control +// board so the board can show what a seat was told to do instead of guessing. +// +// A registration is rewritten on every launch. It is a launch record, not a +// board file: the board must not write here, and a scan never changes it. +// `mosaic seat task ` changes the task field only. +// +// Records are keyed by layout and seat name, ///, because +// a name alone is not unique (this repository and the fleet both have a +// "darkwing"). Two layouts of seat directory are known: +// repo /agents//launch.sh, where /.git exists. +// Sessions live in /.pi/state//sessions. +// fleet /launch.sh with /.pi (the ~/.mosaic fleet layout). +// Sessions live in /.pi/agent/sessions. +// Anything else is layout "unknown" and records nulls; nothing is guessed. + +import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs"; +import { join, basename, dirname, isAbsolute, resolve } from "node:path"; +import { homedir } from "node:os"; +import { spawnSync } from "node:child_process"; + +export const REGISTRATION_VERSION = 1; +export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; +export const TASK_LIMIT = 2000; + +// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or +// configuration, 4 usage. +export class SeatError extends Error { + constructor(message, exitCode = 2) { + super(message); + this.name = "SeatError"; + this.exitCode = exitCode; + } +} + +export function defaultConfigPath(env = process.env) { + return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json"); +} + +// Fail closed: the config must exist, parse, and name an absolute dataRoot. +// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation. +export function loadDataRoot(path = defaultConfigPath()) { + if (!existsSync(path)) throw new SeatError(`config not found: ${path}`); + let raw; + try { + raw = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw new SeatError(`config is not valid JSON: ${path} (${err.message})`); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`); + if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`); + return raw.dataRoot; +} + +export function seatsDir(dataRoot) { + return join(dataRoot, "seats"); +} + +export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]); + +// Keyed by layout and seat, because a seat name is not unique across +// layouts (this repository and the fleet both have a "darkwing"). +export function registrationPath(seats, seat, layout) { + if (!SEAT_NAME.test(String(seat))) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4); + if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout: ${JSON.stringify(layout)}`, 4); + return join(seats, layout, seat, "registration.json"); +} + +// Turn "" or "" into everything a launch needs to +// know. A name resolves under /agents; a path is taken as the seat dir. +export function resolveSeat(arg, { repo = process.cwd() } = {}) { + if (typeof arg !== "string" || arg.length === 0) throw new SeatError("seat name or seat directory required", 4); + let seatDir; + if (arg.includes("/") || (existsSync(arg) && statSync(arg).isDirectory())) seatDir = resolve(arg); + else if (SEAT_NAME.test(arg)) seatDir = join(resolve(repo), "agents", arg); + else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4); + const seat = basename(seatDir); + if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4); + if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: ${seatDir}`); + const launchScript = join(seatDir, "launch.sh"); + if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: ${seatDir}`); + if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: ${launchScript}`); + + const parent = dirname(seatDir); + const root = basename(parent) === "agents" ? dirname(parent) : null; + if (root && existsSync(join(root, ".git"))) { + return { + seat, seatDir, launchScript, layout: "repo", + project: basename(root), + sessionsDir: join(root, ".pi", "state", seat, "sessions"), + defaultWorkspace: root, + }; + } + if (existsSync(join(seatDir, ".pi"))) { + return { seat, seatDir, launchScript, layout: "fleet", project: null, sessionsDir: join(seatDir, ".pi", "agent", "sessions"), defaultWorkspace: null }; + } + return { seat, seatDir, launchScript, layout: "unknown", project: null, sessionsDir: null, defaultWorkspace: null }; +} + +// The tmux session this process runs in, from the TMUX/TMUX_PANE variables +// tmux sets for its panes. null outside tmux or when tmux cannot answer. +// socket is null on the default server, else the socket file's name +// (the value tmux -L takes), matching the control board's spec shape. +export function tmuxContext({ env = process.env, exec = spawnSync } = {}) { + const tmux = env.TMUX; + if (typeof tmux !== "string" || tmux.length === 0) return null; + const socketPath = tmux.split(",")[0]; + if (!socketPath) return null; + const args = ["-S", socketPath, "display-message", "-p"]; + if (env.TMUX_PANE) args.push("-t", env.TMUX_PANE); + args.push("#{session_name}"); + const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 }); + if (r.error || r.status !== 0) return null; + const session = String(r.stdout ?? "").trim(); + if (!session) return null; + const socket = basename(socketPath); + return { socket: socket === "default" ? null : socket, session }; +} + +const FIELDS = Object.freeze([ + "version", "seat", "project", "task", "workspace", "tmux", "harness", + "startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt", +]); + +const isNullableString = (v) => v === null || typeof v === "string"; +const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v)); + +// Shape check for a record read from disk or about to be written. Throws +// SeatError with the failing field; never echoes the offending value. +export function validateRegistration(record) { + if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object"); + for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${key}`); + if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version"); + if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid"); + if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string"); + for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) { + if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`); + } + if (!LAYOUTS.includes(record.layout)) throw new SeatError("registration.layout is invalid"); + if (record.tmux !== null) { + const t = record.tmux; + if (!t || typeof t !== "object" || Array.isArray(t)) throw new SeatError("registration.tmux must be an object or null"); + if (!isNullableString(t.socket) || typeof t.session !== "string") throw new SeatError("registration.tmux is invalid"); + } + if (!isTimestamp(record.startedAt)) throw new SeatError("registration.startedAt must be a timestamp"); + if (record.updatedAt !== null && !isTimestamp(record.updatedAt)) throw new SeatError("registration.updatedAt must be a timestamp or null"); + if (record.pid !== null && !(Number.isInteger(record.pid) && record.pid > 0)) throw new SeatError("registration.pid must be a positive integer or null"); + return record; +} + +export function makeRegistration({ resolved, task = "", project, workspace, harness = null, tmux = null, pid = null, now = () => new Date() }) { + if (typeof task !== "string") throw new SeatError("task must be a string", 4); + if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4); + return validateRegistration({ + version: REGISTRATION_VERSION, + seat: resolved.seat, + project: project ?? resolved.project ?? null, + task, + workspace: workspace ?? resolved.defaultWorkspace ?? null, + tmux, + harness, + startedAt: now().toISOString(), + pid, + sessionsDir: resolved.sessionsDir, + seatDir: resolved.seatDir, + launchScript: resolved.launchScript, + layout: resolved.layout, + updatedAt: null, + }); +} + +// Private: directory 0700, file 0600, atomic tmp+rename so a reader never +// sees a half-written record. +export function writeRegistration(seats, record) { + validateRegistration(record); + const path = registrationPath(seats, record.seat, record.layout); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); + return path; +} + +// null when the seat has no registration. A present but unreadable or +// malformed record throws SeatError rather than being treated as absent. +export function readRegistration(seats, seat, layout) { + const path = registrationPath(seats, seat, layout); + if (!existsSync(path)) return null; + let parsed; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`); + } + try { + const record = validateRegistration(parsed); + if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path"); + return record; + } catch (err) { + throw new SeatError(`${err.message}: ${path}`); + } +} + +// Every registration for a seat name, across layouts. Unreadable records +// throw; a name with no record gives []. +export function findRegistrations(seats, seat) { + return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean); +} + +// Change the task field only. Refuses when the seat was never launched +// through `mosaic launch`, because there is nothing to attach the task to. +// A name that exists in more than one layout must be qualified with layout. +export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) { + if (typeof task !== "string") throw new SeatError("task must be a string", 4); + if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4); + const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat); + if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1); + if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4); + const record = found[0]; + const updated = { ...record, task, updatedAt: now().toISOString() }; + writeRegistration(seats, updated); + return updated; +} + +// True when two paths name the same directory: equal once resolved, or the +// same real path when both exist (symlinked checkouts). +export function samePath(a, b) { + if (typeof a !== "string" || typeof b !== "string") return false; + if (resolve(a) === resolve(b)) return true; + try { + return realpathSync(a) === realpathSync(b); + } catch { + return false; + } +} diff --git a/packages/seat/tests/seat.test.mjs b/packages/seat/tests/seat.test.mjs new file mode 100644 index 00000000..6b9fa14f --- /dev/null +++ b/packages/seat/tests/seat.test.mjs @@ -0,0 +1,562 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + rmSync, + chmodSync, + symlinkSync, + statSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname, basename } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + SeatError, + loadDataRoot, + seatsDir, + registrationPath, + resolveSeat, + tmuxContext, + validateRegistration, + makeRegistration, + writeRegistration, + readRegistration, + updateTask, + findRegistrations, + samePath, + TASK_LIMIT, +} from "../src/seat.mjs"; + +const pkgRoot = resolve(import.meta.dirname, ".."); +const cli = join(pkgRoot, "src", "cli.mjs"); + +// Track every tmpdir so a stray failure never leaves fixtures behind. +const roots = []; +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), "seat-test-")); + roots.push(root); + return root; +} +after(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); + +function writeFile(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function writeExecutable(path, content) { + writeFile(path, content); + chmodSync(path, 0o755); +} + +const FAKE_LAUNCH_SH = [ + "#!/usr/bin/env bash", + 'printf \'%s\\n\' "$@" > "$(dirname "$0")/args.txt"', + 'printf \'%s\\n\' "${MOSAIC_LAUNCH_REGISTERED:-unset}" > "$(dirname "$0")/env.txt"', + 'exit "${FAKE_EXIT:-0}"', + "", +].join("\n"); + +// Repo layout fixture: /.git, /agents//launch.sh. +function buildRepoSeat(root, seat, { launchContent = FAKE_LAUNCH_SH, executable = true } = {}) { + mkdirSync(join(root, ".git"), { recursive: true }); + const seatDir = join(root, "agents", seat); + const launchScript = join(seatDir, "launch.sh"); + if (executable) writeExecutable(launchScript, launchContent); + else writeFile(launchScript, launchContent); + return seatDir; +} + +// Fleet layout fixture: /fleet/agents//{launch.sh,.pi/}, no /.git. +function buildFleetSeat(root, seat) { + const seatDir = join(root, "fleet", "agents", seat); + writeExecutable(join(seatDir, "launch.sh"), FAKE_LAUNCH_SH); + mkdirSync(join(seatDir, ".pi"), { recursive: true }); + return seatDir; +} + +function buildConfig(root, dataRoot = join(root, "data")) { + const configPath = join(root, "config.json"); + writeFile(configPath, JSON.stringify({ configVersion: 1, dataRoot })); + return { configPath, dataRoot }; +} + +function cliEnv(overrides = {}) { + const env = { ...process.env, TMUX: "", TMUX_PANE: "" }; + delete env.MOSAIC_LAUNCH_REGISTERED; + return { ...env, ...overrides }; +} + +function runCli(args, { cwd, env = cliEnv() } = {}) { + return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8", env, timeout: 15000 }); +} + +function baseResolved(seatDir, seat = "myseat") { + return { + seat, + seatDir, + launchScript: join(seatDir, "launch.sh"), + layout: "repo", + project: "someproject", + sessionsDir: join(seatDir, "sessions"), + defaultWorkspace: dirname(dirname(seatDir)), + }; +} + +// --------------------------------------------------------------------------- +// 1-3. resolveSeat +// --------------------------------------------------------------------------- + +test("resolveSeat: by name under --repo resolves the repo layout", () => { + const root = makeRoot(); + const seatDir = buildRepoSeat(root, "myseat"); + const resolved = resolveSeat("myseat", { repo: root }); + assert.equal(resolved.seat, "myseat"); + assert.equal(resolved.seatDir, seatDir); + assert.equal(resolved.layout, "repo"); + assert.equal(resolved.project, basename(root)); + assert.equal(resolved.sessionsDir, join(root, ".pi", "state", "myseat", "sessions")); + assert.equal(resolved.defaultWorkspace, root); + assert.equal(resolved.launchScript, join(seatDir, "launch.sh")); +}); + +test("resolveSeat: by path resolves the fleet layout", () => { + const root = makeRoot(); + const seatDir = buildFleetSeat(root, "myseat"); + const resolved = resolveSeat(seatDir); + assert.equal(resolved.seat, "myseat"); + assert.equal(resolved.layout, "fleet"); + assert.equal(resolved.project, null); + assert.equal(resolved.sessionsDir, join(seatDir, ".pi", "agent", "sessions")); + assert.equal(resolved.defaultWorkspace, null); +}); + +test("resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout", () => { + const root = makeRoot(); + + // Missing seat directory entirely. + assert.throws(() => resolveSeat(join(root, "agents", "ghost")), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 2); + return true; + }); + + // Seat directory exists but has no launch.sh. + const noLaunchDir = join(root, "agents", "nolaunch"); + mkdirSync(noLaunchDir, { recursive: true }); + assert.throws(() => resolveSeat(noLaunchDir), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 2); + return true; + }); + + // launch.sh present but not executable. + const notExecDir = join(root, "agents", "notexec"); + writeFile(join(notExecDir, "launch.sh"), FAKE_LAUNCH_SH); + chmodSync(join(notExecDir, "launch.sh"), 0o644); + assert.throws(() => resolveSeat(notExecDir), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 2); + return true; + }); + + // Invalid seat name. + assert.throws(() => resolveSeat("Bad Name"), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 4); + return true; + }); + + // Parent not named "agents", no .git, no .pi: layout "unknown". + const looseDir = join(root, "loose", "myseat"); + writeExecutable(join(looseDir, "launch.sh"), FAKE_LAUNCH_SH); + const resolved = resolveSeat(looseDir); + assert.equal(resolved.layout, "unknown"); + assert.equal(resolved.project, null); + assert.equal(resolved.sessionsDir, null); + assert.equal(resolved.defaultWorkspace, null); +}); + +// --------------------------------------------------------------------------- +// 4. tmuxContext +// --------------------------------------------------------------------------- + +test("tmuxContext: outside tmux, default socket, custom socket, and exec failure", () => { + // No TMUX var at all. + assert.equal(tmuxContext({ env: {} }), null); + // Empty TMUX (matches TMUX: "" in CLI fixtures). + assert.equal(tmuxContext({ env: { TMUX: "" } }), null); + + // Default socket, with TMUX_PANE set: session name comes from exec stdout. + let captured = null; + const okExec = (cmd, args) => { + captured = { cmd, args }; + return { status: 0, stdout: "sess\n" }; + }; + const result = tmuxContext({ + env: { TMUX: "/tmp/tmux-1000/default,123,0", TMUX_PANE: "%3" }, + exec: okExec, + }); + assert.deepEqual(result, { socket: null, session: "sess" }); + assert.equal(captured.cmd, "tmux"); + assert.deepEqual(captured.args.slice(0, 4), ["-S", "/tmp/tmux-1000/default", "display-message", "-p"]); + assert.ok(captured.args.includes("-t")); + assert.equal(captured.args[captured.args.indexOf("-t") + 1], "%3"); + + // Non-default socket name is surfaced. + const namedSocket = tmuxContext({ + env: { TMUX: "/tmp/tmux-1000/mosaic-fleet,1,0" }, + exec: () => ({ status: 0, stdout: "other\n" }), + }); + assert.deepEqual(namedSocket, { socket: "mosaic-fleet", session: "other" }); + + // Non-zero exit and exec error both yield null. + assert.equal(tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ status: 1, stdout: "" }) }), null); + assert.equal( + tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ error: new Error("no tmux"), status: null }) }), + null + ); +}); + +// --------------------------------------------------------------------------- +// 5. makeRegistration + validateRegistration +// --------------------------------------------------------------------------- + +test("makeRegistration produces a record that validates; each shape violation throws SeatError", () => { + const root = makeRoot(); + const seatDir = buildRepoSeat(root, "myseat"); + const resolved = resolveSeat("myseat", { repo: root }); + const record = makeRegistration({ resolved, task: "do things", pid: 1234 }); + assert.deepEqual(validateRegistration(record), record); + + const mutate = (patch) => ({ ...record, ...patch }); + + assert.throws(() => validateRegistration(mutate({ bogusField: "x" })), SeatError); + assert.throws(() => validateRegistration(mutate({ version: 2 })), SeatError); + assert.throws(() => validateRegistration(mutate({ task: "x".repeat(TASK_LIMIT + 1) })), SeatError); + assert.throws(() => validateRegistration(mutate({ pid: 1.5 })), SeatError); + assert.throws(() => validateRegistration(mutate({ pid: "1234" })), SeatError); + assert.throws(() => validateRegistration(mutate({ tmux: { socket: 5, session: "s" } })), SeatError); + assert.throws(() => validateRegistration(mutate({ tmux: { socket: null } })), SeatError); +}); + +// --------------------------------------------------------------------------- +// 6. writeRegistration / readRegistration +// --------------------------------------------------------------------------- + +test("writeRegistration/readRegistration: round trip, permissions, absence, and malformed records", () => { + const root = makeRoot(); + const seatDir = buildRepoSeat(root, "myseat"); + const resolved = resolveSeat("myseat", { repo: root }); + const seats = seatsDir(join(root, "data")); + const record = makeRegistration({ resolved, task: "hello", pid: 42 }); + + const path = writeRegistration(seats, record); + assert.deepEqual(readRegistration(seats, "myseat", "repo"), record); + + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.equal(statSync(dirname(path)).mode & 0o777, 0o700); + + // No leftover *.tmp-* artifacts after the atomic write. + for (const name of readdirSync(dirname(path))) assert.ok(!name.includes(".tmp-"), `leftover tmp file: ${name}`); + + // Missing registration reads as null. + assert.equal(readRegistration(seats, "ghost", "repo"), null); + + // Malformed JSON throws. + const malformedSeat = "malformed"; + writeFile(registrationPath(seats, malformedSeat, "repo"), "{ not json"); + assert.throws(() => readRegistration(seats, malformedSeat, "repo"), SeatError); + + // A record with an unknown field throws rather than reading as absent. + const unknownFieldSeat = "unknownfield"; + const badRecord = { ...record, seat: unknownFieldSeat, extra: "nope" }; + writeFile(registrationPath(seats, unknownFieldSeat, "repo"), JSON.stringify(badRecord)); + assert.throws(() => readRegistration(seats, unknownFieldSeat, "repo"), (err) => { + assert.ok(err instanceof SeatError); + assert.match(err.message, /unknown field/); + return true; + }); +}); + +// --------------------------------------------------------------------------- +// 7. updateTask +// --------------------------------------------------------------------------- + +test("updateTask: changes task and updatedAt only, and refuses appropriately", () => { + const root = makeRoot(); + const seatDir = buildRepoSeat(root, "myseat"); + const resolved = resolveSeat("myseat", { repo: root }); + const seats = seatsDir(join(root, "data")); + const record = makeRegistration({ resolved, task: "original", pid: 1 }); + writeRegistration(seats, record); + + const updated = updateTask(seats, "myseat", "revised"); + assert.equal(updated.task, "revised"); + assert.notEqual(updated.updatedAt, null); + for (const key of Object.keys(record)) { + if (key === "task" || key === "updatedAt") continue; + assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`); + } + + // Refuses when there is no registration to attach to. + assert.throws(() => updateTask(seats, "ghost", "x"), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 1); + return true; + }); + + // Refuses an over-limit task. + assert.throws(() => updateTask(seats, "myseat", "x".repeat(TASK_LIMIT + 1)), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 4); + return true; + }); + + // The same name in a second layout makes the bare name ambiguous; the + // layout qualifier resolves it and each layout keeps its own record. + const fleetRecord = { ...record, layout: "fleet", sessionsDir: join(root, "fleet", "myseat", ".pi", "agent", "sessions"), task: "fleet task" }; + writeRegistration(seats, fleetRecord); + assert.throws(() => updateTask(seats, "myseat", "x"), (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 4); + assert.match(err.message, /more than one layout/); + return true; + }); + assert.equal(updateTask(seats, "myseat", "fleet revised", { layout: "fleet" }).task, "fleet revised"); + assert.equal(readRegistration(seats, "myseat", "repo").task, "revised"); + assert.equal(readRegistration(seats, "myseat", "fleet").task, "fleet revised"); + assert.equal(findRegistrations(seats, "myseat").length, 2); +}); + +// --------------------------------------------------------------------------- +// 8. CLI launch end to end +// --------------------------------------------------------------------------- + +test("CLI launch: registers, execs the fake launch script, and passes args through", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath, dataRoot } = buildConfig(root); + + const r = runCli(["launch", "myseat", "--task", "do x", "--config", configPath, "--", "--foo", "bar"], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r.status, 0, r.stderr); + + const seatDir = join(root, "agents", "myseat"); + assert.equal(readFileSync(join(seatDir, "args.txt"), "utf8"), "--foo\nbar\n"); + + const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); + assert.equal(readFileSync(join(seatDir, "env.txt"), "utf8").trim(), path); + + const record = JSON.parse(readFileSync(path, "utf8")); + assert.equal(record.seat, "myseat"); + assert.equal(record.project, basename(root)); + assert.equal(record.task, "do x"); + assert.equal(record.workspace, root); + assert.equal(record.harness, null); + assert.equal(record.tmux, null); + assert.ok(Number.isInteger(record.pid) && record.pid > 0); + assert.equal(record.layout, "repo"); + assert.ok(Number.isFinite(Date.parse(record.startedAt))); +}); + +test("CLI launch: --harness lands in the record", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath, dataRoot } = buildConfig(root); + + const r = runCli(["launch", "myseat", "--task", "t", "--harness", "pi", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r.status, 0, r.stderr); + + const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); + const record = JSON.parse(readFileSync(path, "utf8")); + assert.equal(record.harness, "pi"); +}); + +test("CLI launch: the launch script's own exit code passes through", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath } = buildConfig(root); + + const r = runCli(["launch", "myseat", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath, FAKE_EXIT: "7" }), + }); + assert.equal(r.status, 7, r.stderr); +}); + +// --------------------------------------------------------------------------- +// 9. CLI relaunch rewrites the single registration file +// --------------------------------------------------------------------------- + +test("CLI launch: relaunching a seat rewrites the one registration record", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath, dataRoot } = buildConfig(root); + + const r1 = runCli(["launch", "myseat", "--task", "first", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r1.status, 0, r1.stderr); + const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); + const record1 = JSON.parse(readFileSync(path, "utf8")); + + const r2 = runCli(["launch", "myseat", "--task", "second", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r2.status, 0, r2.stderr); + const record2 = JSON.parse(readFileSync(path, "utf8")); + + assert.deepEqual(readdirSync(dirname(path)), ["registration.json"]); + assert.equal(record2.task, "second"); + assert.ok(Date.parse(record2.startedAt) >= Date.parse(record1.startedAt)); +}); + +// --------------------------------------------------------------------------- +// 10. CLI launch with no --task +// --------------------------------------------------------------------------- + +test("CLI launch: omitting --task records an empty string, not null", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath, dataRoot } = buildConfig(root); + + const r = runCli(["launch", "myseat", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r.status, 0, r.stderr); + + const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); + const record = JSON.parse(readFileSync(path, "utf8")); + assert.equal(record.task, ""); +}); + +// --------------------------------------------------------------------------- +// 11. CLI seat task +// --------------------------------------------------------------------------- + +test("CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat", () => { + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const { configPath, dataRoot } = buildConfig(root); + + const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(launchResult.status, 0, launchResult.stderr); + + const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); + const before = JSON.parse(readFileSync(path, "utf8")); + + const r = runCli(["seat", "task", "myseat", "new text", "--config", configPath], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: configPath }), + }); + assert.equal(r.status, 0, r.stderr); + + const after1 = JSON.parse(readFileSync(path, "utf8")); + assert.equal(after1.task, "new text"); + for (const key of Object.keys(before)) { + if (key === "task" || key === "updatedAt") continue; + assert.deepEqual(after1[key], before[key], `field ${key} changed unexpectedly`); + } + + // No registration for a seat that was never launched. + const root2 = makeRoot(); + buildRepoSeat(root2, "unlaunched"); + const { configPath: configPath2 } = buildConfig(root2); + const r2 = runCli(["seat", "task", "unlaunched", "text", "--config", configPath2], { + cwd: root2, + env: cliEnv({ MOSAIC_CONFIG: configPath2 }), + }); + assert.equal(r2.status, 1); + assert.match(r2.stderr, /no registration/); +}); + +// --------------------------------------------------------------------------- +// 12. CLI refusals +// --------------------------------------------------------------------------- + +test("CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure", () => { + // No args: usage, exit 4. + const rNoArgs = runCli([]); + assert.equal(rNoArgs.status, 4); + assert.match(rNoArgs.stderr, /usage: mosaic launch/); + + // Unknown flag: exit 4. + const rUnknown = runCli(["launch", "myseat", "--bogus"]); + assert.equal(rUnknown.status, 4); + assert.match(rUnknown.stderr, /unknown argument/); + + // Missing config with an otherwise-valid seat: exit 2. + const root = makeRoot(); + buildRepoSeat(root, "myseat"); + const missingConfig = join(root, "missing.json"); + assert.ok(!existsSync(missingConfig)); + const rMissingConfig = runCli(["launch", "myseat", "--config", missingConfig], { + cwd: root, + env: cliEnv({ MOSAIC_CONFIG: missingConfig }), + }); + assert.equal(rMissingConfig.status, 2); + assert.match(rMissingConfig.stderr, /config not found/); + + // MOSAIC_LAUNCH_REGISTERED already set: exit 4, no registration written. + const root2 = makeRoot(); + buildRepoSeat(root2, "myseat"); + const { configPath: configPath2, dataRoot: dataRoot2 } = buildConfig(root2); + const rRegistered = runCli(["launch", "myseat", "--config", configPath2], { + cwd: root2, + env: cliEnv({ MOSAIC_CONFIG: configPath2, MOSAIC_LAUNCH_REGISTERED: "/some/path" }), + }); + assert.equal(rRegistered.status, 4); + assert.match(rRegistered.stderr, /MOSAIC_LAUNCH_REGISTERED/); + assert.ok(!existsSync(registrationPath(seatsDir(dataRoot2), "myseat", "repo"))); + + // Launch script that fails to exec: exit 1, no registration left behind. + const root3 = makeRoot(); + buildRepoSeat(root3, "myseat", { launchContent: "#!/nonexistent/interp\necho hi\n" }); + const { configPath: configPath3, dataRoot: dataRoot3 } = buildConfig(root3); + const rExecFail = runCli(["launch", "myseat", "--config", configPath3], { + cwd: root3, + env: cliEnv({ MOSAIC_CONFIG: configPath3 }), + }); + assert.equal(rExecFail.status, 1); + assert.match(rExecFail.stderr, /could not run/); + assert.ok(!existsSync(registrationPath(seatsDir(dataRoot3), "myseat", "repo"))); +}); + +// --------------------------------------------------------------------------- +// 13. samePath +// --------------------------------------------------------------------------- + +test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", () => { + const root = makeRoot(); + const target = join(root, "target"); + mkdirSync(target); + const other = join(root, "other"); + mkdirSync(other); + const link = join(root, "link"); + symlinkSync(target, link, "dir"); + + assert.equal(samePath(target, target), true); + assert.equal(samePath(target, link), true); + assert.equal(samePath(target, other), false); + assert.equal(samePath(1, "x"), false); + assert.equal(samePath(null, target), false); +}); diff --git a/scripts/agent-host-dev.sh b/scripts/agent-host-dev.sh index e9037d14..f65fbc88 100755 --- a/scripts/agent-host-dev.sh +++ b/scripts/agent-host-dev.sh @@ -67,7 +67,8 @@ USER_FILE="${USER_FILE:-$MOSAIC_DATA_ROOT/user/USER.md}" PI="$REPO/node_modules/.bin/pi" [ -x "$PI" ] || fail 'run npm ci --ignore-scripts --no-audit --no-fund in the repository first' PIN="$(node -p "require('./package.json').dependencies['@earendil-works/pi-coding-agent']")" -[ "$("$PI" --version)" = "$PIN" ] || fail "local Pi must match package.json ($PIN); run npm ci" +PI_VERSION="$("$PI" --version)" +[ "$PI_VERSION" = "$PIN" ] || fail "local Pi is $PI_VERSION, package.json pins $PIN; run npm ci in $REPO" command -v flock >/dev/null || fail 'flock is required' CONTEXT_FILES=("$CONSTITUTION" "$REPO/contracts/STANDARDS.md" "$SOUL" "$USER_FILE" "$REPO/AGENTS.md" "$REPO/agents/$AGENT/CONTEXT.md") for file in "${CONTEXT_FILES[@]}"; do diff --git a/scripts/mosaic b/scripts/mosaic new file mode 100755 index 00000000..0a50fdb7 --- /dev/null +++ b/scripts/mosaic @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# `mosaic launch ` and `mosaic seat task `: seat launch +# with registration for the control board. See packages/seat/README.md. +# Not the npm-global `mosaic` CLI from the estate tooling; this one is +# repository-local and only reachable as scripts/mosaic. +set -euo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec node "$REPO/packages/seat/src/cli.mjs" "$@" diff --git a/scripts/test-darkwing-launch.mjs b/scripts/test-darkwing-launch.mjs index 1b745e8f..1fa84730 100644 --- a/scripts/test-darkwing-launch.mjs +++ b/scripts/test-darkwing-launch.mjs @@ -1,9 +1,9 @@ // Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, cpSync, existsSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, cpSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { join, resolve, basename } from 'node:path'; import { spawnSync } from 'node:child_process'; const source = resolve(import.meta.dirname, '..'); @@ -23,6 +23,14 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont assert.equal(existsSync(join(root, `agents/${agent}/scratch`)), false); cpSync(join(source, 'scripts/agent-host-dev.sh'), join(root, 'scripts/agent-host-dev.sh')); cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh')); + // Seat registration (packages/seat): the launch script re-enters itself + // through scripts/mosaic, which needs a config naming a data root. The + // fixture is its own data root so no registration reaches a real one. + cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic')); + cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true }); + mkdirSync(join(root, '.git')); + writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root })); + const registration = join(root, `seats/repo/${agent}/registration.json`); cpSync(join(source, 'skills'), join(root, 'skills'), { recursive: true }); writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@earendil-works/pi-coding-agent': '0.85.1' } })); for (const file of ['contracts/CONSTITUTION.md', 'contracts/STANDARDS.md', 'user/USER.md', 'AGENTS.md']) { @@ -34,14 +42,15 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont writeFileSync(join(root, 'node_modules/.bin/pi'), `#!/usr/bin/env node const fs = require('node:fs'); if (process.argv[2] === '--version') { console.log('0.85.1'); process.exit(0); } -fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION})); +fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED})); `, { mode: 0o755 }); const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty ? ['-q', '-e', '-c', `bash agents/${agent}/launch.sh ${args}`, '/dev/null'] : [`agents/${agent}/launch.sh`, ...args.split(' ').filter(Boolean)], - { cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit' }, encoding: 'utf8', timeout: 10000 }); + { cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 }); const ok = (result) => assert.equal(result.status, 0, result.stdout + result.stderr); ok(launch('--check', false)); + assert.equal(existsSync(join(root, 'seats')), false); const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', agent, '--check'], { cwd: tmpdir(), encoding: 'utf8', timeout: 10000 }); ok(direct); @@ -61,6 +70,18 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag let captured = JSON.parse(readFileSync(join(root, 'capture.json'))); assert.equal(captured.incarnation, undefined); assert.equal(captured.agent, agent); + // The seat registered itself before Pi started, and Pi saw the record path. + assert.equal(captured.registered, registration); + const record = JSON.parse(readFileSync(registration, 'utf8')); + assert.equal(record.seat, agent); + assert.equal(record.harness, 'pi'); + assert.equal(record.layout, 'repo'); + assert.equal(record.project, basename(root)); + assert.equal(record.workspace, root); + assert.equal(record.task, ''); + assert.equal(record.sessionsDir, join(root, `.pi/state/${agent}/sessions`)); + assert.ok(Number.isInteger(record.pid) && record.pid > 0); + const firstStart = record.startedAt; assert.equal(captured.args[captured.args.indexOf('--provider') + 1], agent === 'filbert' ? 'openai-codex' : 'test'); assert.equal(captured.args[captured.args.indexOf('--model') + 1], agent === 'filbert' ? 'gpt-6-astra' : 'test-model'); if (agent === 'filbert') assert.equal(captured.args[captured.args.indexOf('--thinking') + 1], 'low'); @@ -88,6 +109,15 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag assert.notEqual(captured.args[captured.args.indexOf('--append-system-prompt') + 1], firstPrompt); ok(launch('--fresh')); assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).args.includes('--continue'), false); + // Relaunch rewrites the one record; nothing accumulates. + const relaunched = JSON.parse(readFileSync(registration, 'utf8')); + assert.ok(Date.parse(relaunched.startedAt) >= Date.parse(firstStart)); + assert.deepEqual(readdirSync(join(root, `seats/repo/${agent}`)), ['registration.json']); + // Launching through scripts/mosaic directly is the same path, with a task. + ok(spawnSync('script', ['-q', '-e', '-c', `scripts/mosaic launch --repo "${root}" --task "fixture task" ${agent} -- --fresh`, '/dev/null'], + { cwd: root, env: { ...process.env, MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 })); + assert.equal(JSON.parse(readFileSync(registration, 'utf8')).task, 'fixture task'); + assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).registered, registration); assert.equal(readFileSync(session, 'utf8'), history); writeFileSync(session, 'broken JSON\n'); assert.notEqual(launch().status, 0); diff --git a/scripts/test-rocko-launch.mjs b/scripts/test-rocko-launch.mjs index cb7fe0e8..e21fd628 100644 --- a/scripts/test-rocko-launch.mjs +++ b/scripts/test-rocko-launch.mjs @@ -16,13 +16,19 @@ test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusal writeFileSync(join(root, file), `fixture ${file}\n`); } writeFileSync(join(root, 'scripts/common.sh'), 'load_config() { MOSAIC_DATA_ROOT="$PWD"; }\n'); + // Seat registration (packages/seat): the fixture is its own data root. + cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic')); + cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true }); + mkdirSync(join(root, '.git')); + writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root })); + const registration = join(root, 'seats/repo/rocko/registration.json'); writeFileSync(join(root, 'bin/claude'), `#!/usr/bin/env node const fs = require('node:fs'); if (process.argv[2] === '--version') { console.log('fixture Claude'); process.exit(0); } -fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION})); +fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED})); if (process.env.FAKE_CLAUDE_FAIL) process.exit(7); `, { mode: 0o755 }); - const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit' }; + const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }; const run = (args = '', tty = true, extraEnv = {}) => spawnSync(tty ? 'script' : 'bash', tty ? ['-q', '-e', '-c', `bash agents/rocko/launch.sh ${args}`, '/dev/null'] : [join(root, 'agents/rocko/launch.sh'), ...args.split(' ').filter(Boolean)], @@ -33,10 +39,19 @@ if (process.env.FAKE_CLAUDE_FAIL) process.exit(7); ok(run('--check', false)); assert.equal(existsSync(state), false); assert.equal(existsSync(join(root, 'capture.json')), false); + assert.equal(existsSync(join(root, 'seats')), false); ok(run()); let captured = capture(); assert.equal(captured.cwd, root); assert.equal(captured.agent, 'rocko'); + // Registered before Claude started; the record names the Claude harness. + assert.equal(captured.registered, registration); + const record = JSON.parse(readFileSync(registration, 'utf8')); + assert.equal(record.seat, 'rocko'); + assert.equal(record.harness, 'claude-code'); + assert.equal(record.layout, 'repo'); + assert.equal(record.workspace, root); + assert.ok(Number.isInteger(record.pid) && record.pid > 0); assert.equal(captured.incarnation, undefined); assert.equal(captured.args[captured.args.indexOf('--model') + 1], 'sonnet'); assert.equal(captured.args[captured.args.indexOf('--name') + 1], 'Rocko');