Add mosaic launch <seat> with seat registration for the control board (#1504)
New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes <dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board reads registrations, matches by sessions directory, and lets a registered task, project or workspace override the derived value with a source tag. The four repository launch scripts register themselves unless already registered or run with --check. Fleet launchers untouched; one-liner on the plan page. Review found the record path keyed by seat name alone (repo and fleet "darkwing" would collide); fixed by keying on layout. Also: the Pi pin refusal now names installed and required versions. Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -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
|
its active project because `~/.mosaic` is a git checkout and the roster
|
||||||
names no project. Missing launcher signals are listed in the plan page
|
names no project. Missing launcher signals are listed in the plan page
|
||||||
Step 3 log. Sonnet review: APPROVED, no findings.
|
Step 3 log. Sonnet review: APPROVED, no findings.
|
||||||
|
|
||||||
|
## 2026-09-12 — Seat registration: `mosaic launch <seat>` (#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 <seat|seat-dir> [--task ..] [-- args]`
|
||||||
|
(package `packages/seat`) writes
|
||||||
|
`<dataRoot>/seats/<layout>/<seat>/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
|
||||||
|
<seat> <text>` 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.
|
||||||
|
|||||||
@@ -2,4 +2,9 @@
|
|||||||
# Darkwing's native development mode through the Mosaic agent entry point.
|
# Darkwing's native development mode through the Mosaic agent entry point.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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 "$@"
|
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
|
||||||
|
|||||||
@@ -2,4 +2,9 @@
|
|||||||
# Dewey's native development mode through the Mosaic agent entry point.
|
# Dewey's native development mode through the Mosaic agent entry point.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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 "$@"
|
exec "$REPO/scripts/agent.sh" --host-dev dewey "$@"
|
||||||
|
|||||||
@@ -2,5 +2,10 @@
|
|||||||
# Filbert's native development mode through the Mosaic agent entry point.
|
# Filbert's native development mode through the Mosaic agent entry point.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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 "$@" \
|
exec "$REPO/scripts/agent.sh" --host-dev filbert "$@" \
|
||||||
--provider openai-codex --model gpt-6-astra --thinking low
|
--provider openai-codex --model gpt-6-astra --thinking low
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
# Rocko's native Claude Code development session.
|
# Rocko's native Claude Code development session.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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"
|
cd "$REPO"
|
||||||
fail() { echo "rocko: $*" >&2; exit 1; }
|
fail() { echo "rocko: $*" >&2; exit 1; }
|
||||||
usage() {
|
usage() {
|
||||||
|
|||||||
@@ -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, 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, 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 — 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 <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/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.
|
||||||
|
|||||||
@@ -130,6 +130,23 @@ Push is never automatic.
|
|||||||
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
|
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
|
||||||
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
|
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
|
||||||
|
|
||||||
|
## Seat launch with registration (`scripts/mosaic`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||||
|
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and
|
||||||
|
leaves one registration record at `<dataRoot>/seats/<layout>/<seat>/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)
|
## Tools (host-side)
|
||||||
|
|
||||||
Host-side helpers under `tools/`, outside the `scripts/` command surface.
|
Host-side helpers under `tools/`, outside the `scripts/` command surface.
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ Two kinds of agents are scanned today:
|
|||||||
|
|
||||||
- One file per agent: `<dataRoot>/board/sessions/<project>/<agent>.json`
|
- One file per agent: `<dataRoot>/board/sessions/<project>/<agent>.json`
|
||||||
- One summary file for the whole board: `<dataRoot>/board/index.json`
|
- One summary file for the whole board: `<dataRoot>/board/index.json`
|
||||||
|
- One registration per seat started through `mosaic launch`:
|
||||||
|
`<dataRoot>/seats/<layout>/<seat>/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
|
- `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
|
is missing or broken, the scanner refuses to run. It does not guess a
|
||||||
fallback location.
|
fallback location.
|
||||||
@@ -144,12 +148,18 @@ node --test packages/control-board/tests/
|
|||||||
|
|
||||||
## Boundaries
|
## 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 new files at the repository root.
|
||||||
- No secrets read, stored, or printed.
|
- No secrets read, stored, or printed.
|
||||||
- No daemon yet. In step 1 the scanner is run by hand. In step 2 the page
|
- 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.
|
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
|
- This plan does not authorize push or merge beyond whatever the existing
|
||||||
refactor-branch plan already allows.
|
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
|
not start WebUI build work. Sequence stays: seat registration via
|
||||||
`mosaic launch` (in progress), then reply-from-board, then the WebUI on the
|
`mosaic launch` (in progress), then reply-from-board, then the WebUI on the
|
||||||
Console design absorbs the board as its first screen.
|
Console design absorbs the board as its first screen.
|
||||||
|
|
||||||
|
**2026-09-12 — Seat registration: `mosaic launch <seat>` (#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 <seat|seat-dir> [--task ..] [-- args]` resolves
|
||||||
|
`agents/<seat>/launch.sh` (repo layout) or a seat directory (fleet layout,
|
||||||
|
`<seatDir>/.pi`), writes `<dataRoot>/seats/<layout>/<seat>/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 <seat> <text>` 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/<layout>/<seat>/`); `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.
|
||||||
|
|||||||
+21
-7
@@ -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
|
"Waiting on you" until the agent writes again
|
||||||
(`docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md`).
|
(`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
|
Gate A (task, active project, workspace per row) passed on 2026-09-12 and
|
||||||
and reports what is wrong, missing or confusing; each report becomes a small
|
Jason's go on the professor session's brief opened #1504: seat registration.
|
||||||
fix on this package with a test, committed and pushed to `refactor`. Close
|
`scripts/mosaic launch <seat>` runs a seat's launch script unchanged and
|
||||||
#1503 when Jason says the page answers "who is waiting on me" without him
|
leaves one record at `<dataRoot>/seats/<layout>/<seat>/registration.json` that the
|
||||||
opening a terminal. Out of scope until he asks: auth, hooks/plugins, comms,
|
board reads instead of guessing; `scripts/mosaic seat task <seat> <text>`
|
||||||
memory, launcher changes, non-Pi harnesses, new root files. The registry line
|
changes the task. The four repository launch scripts register themselves.
|
||||||
(increment 3, headless identity-env leak) stays parked; #1500 is closed.
|
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)
|
## Completed checkpoint: #1500 increment 2 (historical)
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ node --test packages/control-board/tests/
|
|||||||
|
|
||||||
```
|
```
|
||||||
<dataRoot>/board/
|
<dataRoot>/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)
|
seen.json # Jason's "seen" marks (see below)
|
||||||
sessions/
|
sessions/
|
||||||
<project>/
|
<project>/
|
||||||
@@ -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 —
|
the agent's newest message still has that exact `lastActivity` timestamp —
|
||||||
as soon as the agent writes anything new, `lastActivity` changes, the mark
|
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.
|
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
|
Every row also shows three "what and where" fields, each taken from the
|
||||||
session log and tmux or shown as the word "unknown", never guessed:
|
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
|
- **Task** — the session's first user message (collapsed to one line, 240
|
||||||
characters). pi logs carry no task envelope, so this is the only
|
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
|
tmux could not be asked, the `cwd` from the session log. The record says
|
||||||
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
|
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
|
||||||
|
|
||||||
|
A seat started through `scripts/mosaic launch <seat>` (see
|
||||||
|
`packages/seat/README.md`, #1504) has a registration at
|
||||||
|
`<dataRoot>/seats/<layout>/<seat>/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,
|
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
|
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
|
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",
|
"workspace": "/mnt/storage/src/mosaic-stack",
|
||||||
"workspaceSource": "tmux-pane",
|
"workspaceSource": "tmux-pane",
|
||||||
"activeProject": "mosaic-stack",
|
"activeProject": "mosaic-stack",
|
||||||
|
"activeProjectSource": "workspace-git-root",
|
||||||
|
"registered": null,
|
||||||
"lastActivity": "2026-09-12T15:04:33.000Z",
|
"lastActivity": "2026-09-12T15:04:33.000Z",
|
||||||
"ageSeconds": 42,
|
"ageSeconds": 42,
|
||||||
"lastAssistantText": "Ready for the next step whenever you are.",
|
"lastAssistantText": "Ready for the next step whenever you are.",
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ async function main() {
|
|||||||
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
||||||
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
|
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
|
||||||
const boardDir = join(dataRoot, "board");
|
const boardDir = join(dataRoot, "board");
|
||||||
|
const seatsDir = join(dataRoot, "seats");
|
||||||
if (opts.command === "serve") {
|
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();
|
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`);
|
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));
|
const stop = () => server.close(() => process.exit(0));
|
||||||
@@ -49,7 +50,8 @@ async function main() {
|
|||||||
process.on("SIGTERM", stop);
|
process.on("SIGTERM", stop);
|
||||||
return;
|
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) {
|
if (opts.print) {
|
||||||
for (const s of index.sessions) {
|
for (const s of index.sessions) {
|
||||||
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
|
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(`${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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.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}
|
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.unknown{color:var(--muted);font-style:italic}
|
.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-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{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)}
|
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||||
@@ -184,6 +185,21 @@
|
|||||||
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||||
|
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) {
|
function buildRowPair(rec, showProject) {
|
||||||
var idx = rowIdx++;
|
var idx = rowIdx++;
|
||||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||||
@@ -193,12 +209,14 @@
|
|||||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||||
// 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
|
var task = rec.task
|
||||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>"
|
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
|
||||||
: '<span class="unknown">unknown</span>';
|
: '<span class="unknown">unknown</span>';
|
||||||
var activeProject = rec.activeProject
|
var activeProject = rec.activeProject
|
||||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>"
|
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||||
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
|
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
|
||||||
var main =
|
var main =
|
||||||
'<tr class="' + cls + '">' +
|
'<tr class="' + cls + '">' +
|
||||||
@@ -219,9 +237,10 @@
|
|||||||
'<dl class="detail-list">' +
|
'<dl class="detail-list">' +
|
||||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + "</dd>" +
|
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "</dd>" +
|
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "</dd>" +
|
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||||
|
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
|
||||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||||
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
|
|||||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { spawnSync } from "node:child_process";
|
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"]);
|
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
|
||||||
const TEXT_LIMIT = 240;
|
const TEXT_LIMIT = 240;
|
||||||
@@ -243,15 +244,52 @@ function liveness(result) {
|
|||||||
return { alive: result ?? null, workspace: null };
|
return { alive: result ?? null, workspace: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registrations written by `mosaic launch <seat>` (packages/seat): one
|
||||||
|
// record per seat under <dataRoot>/seats/<layout>/<seat>/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.
|
// One agent -> one status record.
|
||||||
//
|
//
|
||||||
// Three fields answer "what is this seat doing, and where" (Gate A ask,
|
// Three fields answer "what is this seat doing, and where" (Gate A ask,
|
||||||
// 2026-09-12). Each is derived, never guessed; null means "unknown".
|
// 2026-09-12). Each is derived, never guessed; null means "unknown". A seat
|
||||||
// task the session's first user message. The log has no task
|
// started through `mosaic launch` has a registration, and a registered task,
|
||||||
// envelope entry, so this is the only assignment signal it holds.
|
// project or workspace wins over the derived value; the *Source field says
|
||||||
// workspace the live pane path from tmux, else the session log's cwd.
|
// which one the row shows.
|
||||||
// activeProject basename of the nearest git repo root above the workspace.
|
// task registration.task, else the session's first user message
|
||||||
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) {
|
// (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 live = liveness(isAlive(spec.tmux));
|
||||||
const alive = live.alive;
|
const alive = live.alive;
|
||||||
const file = findNewestSession(spec.sessionsDir);
|
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 needsYou = state === "waiting" || state === "error";
|
||||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||||
const cwd = session?.cwd ?? null;
|
const cwd = session?.cwd ?? null;
|
||||||
const workspace = live.workspace ?? cwd;
|
const reg = registration && typeof registration === "object" ? registration : null;
|
||||||
const repoRoot = workspace ? findRepoRoot(workspace) : 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 {
|
return {
|
||||||
agent: spec.agent,
|
agent: spec.agent,
|
||||||
project: spec.project,
|
project: spec.project,
|
||||||
@@ -276,11 +323,15 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
|||||||
sessionFile: file,
|
sessionFile: file,
|
||||||
sessionId: session?.sessionId ?? null,
|
sessionId: session?.sessionId ?? null,
|
||||||
cwd,
|
cwd,
|
||||||
task: session?.firstUserText ?? null,
|
task,
|
||||||
taskSource: session?.firstUserText ? "first-user-message" : null,
|
taskSource,
|
||||||
workspace,
|
workspace,
|
||||||
workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null,
|
workspaceSource,
|
||||||
activeProject: repoRoot ? basename(repoRoot) : null,
|
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,
|
lastActivity,
|
||||||
ageSeconds,
|
ageSeconds,
|
||||||
lastAssistantText: session?.lastAssistantText ?? null,
|
lastAssistantText: session?.lastAssistantText ?? null,
|
||||||
@@ -317,10 +368,13 @@ function writeAtomic(path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.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 (!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 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) {
|
for (const rec of records) {
|
||||||
const dir = join(boardDir, "sessions", rec.project);
|
const dir = join(boardDir, "sessions", rec.project);
|
||||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
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])),
|
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
|
||||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
|
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
|
||||||
seen: records.filter((r) => r.seen).map(seenKey),
|
seen: records.filter((r) => r.seen).map(seenKey),
|
||||||
|
registered: records.filter((r) => r.registered).map(seenKey),
|
||||||
|
registrationErrors,
|
||||||
sessions: records,
|
sessions: records,
|
||||||
};
|
};
|
||||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||||
|
|||||||
@@ -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.
|
// 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) => {
|
return createHttpServer((req, res) => {
|
||||||
const url = new URL(req.url, "http://localhost");
|
const url = new URL(req.url, "http://localhost");
|
||||||
if (req.method === "POST" && url.pathname === "/api/seen") {
|
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 });
|
return sendJson(res, 400, { error: err.message });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
sendJson(res, 200, scan(specs, { boardDir, isAlive, now }));
|
sendJson(res, 200, scan(specs, { boardDir, isAlive, now, seatsDir }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendJson(res, 500, { error: err.message });
|
sendJson(res, 500, { error: err.message });
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage()
|
|||||||
if (url.pathname === "/api/board") {
|
if (url.pathname === "/api/board") {
|
||||||
let index;
|
let index;
|
||||||
try {
|
try {
|
||||||
index = scan(specs, { boardDir, isAlive, now });
|
index = scan(specs, { boardDir, isAlive, now, seatsDir });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
statSync,
|
statSync,
|
||||||
|
symlinkSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve, basename } from "node:path";
|
import { join, resolve, basename } from "node:path";
|
||||||
@@ -31,7 +32,10 @@ import {
|
|||||||
findRepoRoot,
|
findRepoRoot,
|
||||||
loadSeen,
|
loadSeen,
|
||||||
markSeen,
|
markSeen,
|
||||||
|
loadRegistrations,
|
||||||
|
matchRegistration,
|
||||||
} from "../src/scan.mjs";
|
} from "../src/scan.mjs";
|
||||||
|
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
|
||||||
|
|
||||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
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");
|
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
|
// 5. scanAgent
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { spawnSync, spawn } from "node:child_process";
|
|||||||
import { createServer as createNetServer } from "node:net";
|
import { createServer as createNetServer } from "node:net";
|
||||||
import { ConfigError, markSeen } from "../src/scan.mjs";
|
import { ConfigError, markSeen } from "../src/scan.mjs";
|
||||||
import { isLoopbackHost, startServer } from "../src/serve.mjs";
|
import { isLoopbackHost, startServer } from "../src/serve.mjs";
|
||||||
|
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
|
||||||
|
|
||||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||||
@@ -201,6 +202,54 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("startServer: a seatsDir registration overrides the row and index.registered reflects it", async () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const sessionsDir = join(root, "sessions");
|
||||||
|
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||||
|
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||||
|
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
|
||||||
|
]);
|
||||||
|
const boardDir = join(root, "board");
|
||||||
|
const seatsDir = join(root, "seats");
|
||||||
|
const reg = makeRegistration({
|
||||||
|
resolved: {
|
||||||
|
seat: "agent1",
|
||||||
|
project: "proj",
|
||||||
|
sessionsDir,
|
||||||
|
seatDir: join(root, "agent1"),
|
||||||
|
launchScript: join(root, "agent1", "launch.sh"),
|
||||||
|
layout: "repo",
|
||||||
|
defaultWorkspace: null,
|
||||||
|
},
|
||||||
|
task: "registered task",
|
||||||
|
});
|
||||||
|
writeRegistration(seatsDir, reg);
|
||||||
|
|
||||||
|
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||||
|
const server = await startServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
specs,
|
||||||
|
boardDir,
|
||||||
|
seatsDir,
|
||||||
|
isAlive: () => ({ alive: true, workspace: null }),
|
||||||
|
page: "<html></html>",
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/board`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.sessions[0].taskSource, "registration");
|
||||||
|
assert.equal(body.sessions[0].task, "registered task");
|
||||||
|
assert.deepEqual(body.registered, ["proj/agent1"]);
|
||||||
|
assert.deepEqual(body.registrationErrors, []);
|
||||||
|
} finally {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 4. /api/board: scan failure surfaces as a 500 with an error field
|
// 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"));
|
const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
|
||||||
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
|
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
|
||||||
assert.equal(row[0], "s");
|
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.equal(heads.length, 3, "all three tables carry the two new headers");
|
||||||
assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
|
assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 9. page.html: registration source tags and the Registered detail row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped", () => {
|
||||||
|
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
||||||
|
|
||||||
|
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
|
||||||
|
assert.ok(rowPair, "buildRowPair() must exist in page.html");
|
||||||
|
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
|
||||||
|
assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
|
||||||
|
assert.match(
|
||||||
|
rowPair[0],
|
||||||
|
/<dt>Registered<\/dt><dd>" \+ registeredText\(rec\.registered\) \+ "<\/dd>/,
|
||||||
|
"the detail list has a Registered row built by registeredText()",
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/);
|
||||||
|
assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
|
||||||
|
const sourceLabel = JSON.parse(sourceLabelMatch[1]);
|
||||||
|
assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
|
||||||
|
|
||||||
|
// sourceTag() and fromSource() are one-liners in page.html; match them by
|
||||||
|
// line rather than by a "function ... { ... \n }" block regex, which
|
||||||
|
// assumes a closing brace on its own line.
|
||||||
|
const lines = html.split("\n");
|
||||||
|
const sourceTagLine = lines.find((l) => l.includes("function sourceTag(source)"));
|
||||||
|
assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
|
||||||
|
assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
|
||||||
|
assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
|
||||||
|
|
||||||
|
const fromSourceLine = lines.find((l) => l.includes("function fromSource(source)"));
|
||||||
|
assert.ok(fromSourceLine, "fromSource() must exist in page.html");
|
||||||
|
assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
|
||||||
|
|
||||||
|
const registeredTextFn = html.match(/function registeredText\(reg\) \{[\s\S]*?\n \}/);
|
||||||
|
assert.ok(registeredTextFn, "registeredText() must exist in page.html");
|
||||||
|
for (const line of registeredTextFn[0].split("\n")) {
|
||||||
|
if (!/\breg\.[a-zA-Z]/.test(line)) continue;
|
||||||
|
assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# seat
|
||||||
|
|
||||||
|
`mosaic launch <seat>` 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 <seat> <text>` 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 <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||||
|
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||||
|
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<seat>` is a name under `<repo>/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=<record path>`. 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
|
||||||
|
|
||||||
|
`<dataRoot>/seats/<layout>/<seat>/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 `<dataRoot>/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` (`<repo>/agents/<seat>` with `<repo>/.git`), `fleet`
|
||||||
|
(`<seatDir>/.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 `<dataRoot>/seats/<layout>/<seat>/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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaic/seat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Seat launch and registration: `mosaic launch <seat>` 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/" }
|
||||||
|
}
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Usage:
|
||||||
|
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||||
|
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||||
|
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||||
|
//
|
||||||
|
// `launch` writes <dataRoot>/seats/<seat>/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 <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
||||||
|
" mosaic seat task <seat> <text> [--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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Seat registration: one small record per seat under <dataRoot>/seats/<layout>/<seat>/
|
||||||
|
// registration.json, written by `mosaic launch <seat>` 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 <seat> <text>` changes the task field only.
|
||||||
|
//
|
||||||
|
// Records are keyed by layout and seat name, <seats>/<layout>/<seat>/, because
|
||||||
|
// a name alone is not unique (this repository and the fleet both have a
|
||||||
|
// "darkwing"). Two layouts of seat directory are known:
|
||||||
|
// repo <repo>/agents/<seat>/launch.sh, where <repo>/.git exists.
|
||||||
|
// Sessions live in <repo>/.pi/state/<seat>/sessions.
|
||||||
|
// fleet <seatDir>/launch.sh with <seatDir>/.pi (the ~/.mosaic fleet layout).
|
||||||
|
// Sessions live in <seatDir>/.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 "<name>" or "<path to seat dir>" into everything a launch needs to
|
||||||
|
// know. A name resolves under <repo>/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <root>/.git, <root>/agents/<seat>/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: <root>/fleet/agents/<seat>/{launch.sh,.pi/}, no <root>/.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);
|
||||||
|
});
|
||||||
@@ -67,7 +67,8 @@ USER_FILE="${USER_FILE:-$MOSAIC_DATA_ROOT/user/USER.md}"
|
|||||||
PI="$REPO/node_modules/.bin/pi"
|
PI="$REPO/node_modules/.bin/pi"
|
||||||
[ -x "$PI" ] || fail 'run npm ci --ignore-scripts --no-audit --no-fund in the repository first'
|
[ -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']")"
|
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'
|
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")
|
CONTEXT_FILES=("$CONSTITUTION" "$REPO/contracts/STANDARDS.md" "$SOUL" "$USER_FILE" "$REPO/AGENTS.md" "$REPO/agents/$AGENT/CONTEXT.md")
|
||||||
for file in "${CONTEXT_FILES[@]}"; do
|
for file in "${CONTEXT_FILES[@]}"; do
|
||||||
|
|||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: 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" "$@"
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY.
|
// Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve, basename } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
const source = resolve(import.meta.dirname, '..');
|
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);
|
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-host-dev.sh'), join(root, 'scripts/agent-host-dev.sh'));
|
||||||
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.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 });
|
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' } }));
|
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']) {
|
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
|
writeFileSync(join(root, 'node_modules/.bin/pi'), `#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
if (process.argv[2] === '--version') { console.log('0.85.1'); process.exit(0); }
|
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 });
|
`, { mode: 0o755 });
|
||||||
const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty
|
const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty
|
||||||
? ['-q', '-e', '-c', `bash agents/${agent}/launch.sh ${args}`, '/dev/null']
|
? ['-q', '-e', '-c', `bash agents/${agent}/launch.sh ${args}`, '/dev/null']
|
||||||
: [`agents/${agent}/launch.sh`, ...args.split(' ').filter(Boolean)],
|
: [`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);
|
const ok = (result) => assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
ok(launch('--check', false));
|
ok(launch('--check', false));
|
||||||
|
assert.equal(existsSync(join(root, 'seats')), false);
|
||||||
const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', agent, '--check'],
|
const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', agent, '--check'],
|
||||||
{ cwd: tmpdir(), encoding: 'utf8', timeout: 10000 });
|
{ cwd: tmpdir(), encoding: 'utf8', timeout: 10000 });
|
||||||
ok(direct);
|
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')));
|
let captured = JSON.parse(readFileSync(join(root, 'capture.json')));
|
||||||
assert.equal(captured.incarnation, undefined);
|
assert.equal(captured.incarnation, undefined);
|
||||||
assert.equal(captured.agent, agent);
|
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('--provider') + 1], agent === 'filbert' ? 'openai-codex' : 'test');
|
||||||
assert.equal(captured.args[captured.args.indexOf('--model') + 1], agent === 'filbert' ? 'gpt-6-astra' : 'test-model');
|
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');
|
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);
|
assert.notEqual(captured.args[captured.args.indexOf('--append-system-prompt') + 1], firstPrompt);
|
||||||
ok(launch('--fresh'));
|
ok(launch('--fresh'));
|
||||||
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).args.includes('--continue'), false);
|
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);
|
assert.equal(readFileSync(session, 'utf8'), history);
|
||||||
writeFileSync(session, 'broken JSON\n');
|
writeFileSync(session, 'broken JSON\n');
|
||||||
assert.notEqual(launch().status, 0);
|
assert.notEqual(launch().status, 0);
|
||||||
|
|||||||
@@ -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, file), `fixture ${file}\n`);
|
||||||
}
|
}
|
||||||
writeFileSync(join(root, 'scripts/common.sh'), 'load_config() { MOSAIC_DATA_ROOT="$PWD"; }\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
|
writeFileSync(join(root, 'bin/claude'), `#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
if (process.argv[2] === '--version') { console.log('fixture Claude'); process.exit(0); }
|
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);
|
if (process.env.FAKE_CLAUDE_FAIL) process.exit(7);
|
||||||
`, { mode: 0o755 });
|
`, { 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
|
const run = (args = '', tty = true, extraEnv = {}) => spawnSync(tty ? 'script' : 'bash', tty
|
||||||
? ['-q', '-e', '-c', `bash agents/rocko/launch.sh ${args}`, '/dev/null']
|
? ['-q', '-e', '-c', `bash agents/rocko/launch.sh ${args}`, '/dev/null']
|
||||||
: [join(root, 'agents/rocko/launch.sh'), ...args.split(' ').filter(Boolean)],
|
: [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));
|
ok(run('--check', false));
|
||||||
assert.equal(existsSync(state), false);
|
assert.equal(existsSync(state), false);
|
||||||
assert.equal(existsSync(join(root, 'capture.json')), false);
|
assert.equal(existsSync(join(root, 'capture.json')), false);
|
||||||
|
assert.equal(existsSync(join(root, 'seats')), false);
|
||||||
ok(run());
|
ok(run());
|
||||||
let captured = capture();
|
let captured = capture();
|
||||||
assert.equal(captured.cwd, root);
|
assert.equal(captured.cwd, root);
|
||||||
assert.equal(captured.agent, 'rocko');
|
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.incarnation, undefined);
|
||||||
assert.equal(captured.args[captured.args.indexOf('--model') + 1], 'sonnet');
|
assert.equal(captured.args[captured.args.indexOf('--model') + 1], 'sonnet');
|
||||||
assert.equal(captured.args[captured.args.indexOf('--name') + 1], 'Rocko');
|
assert.equal(captured.args[captured.args.indexOf('--name') + 1], 'Rocko');
|
||||||
|
|||||||
Reference in New Issue
Block a user