From ea6dc9eff589d4576f970bb63127f6eb855216b9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 09:33:09 -0500 Subject: [PATCH] row 220 (C2-10a, #1721): M7 hides a creative seat's sessions from the conversation reader, M8 refuses a raw engine on a library seat in discord check and run; library-seats helper in packages/business (rulings da9555ca2); unrun --- agents/researcher/work/queue-220/BUILD.md | 100 ++++++++ agents/researcher/work/queue-220/r2/BUILD.md | 215 ++++++++++++++++++ packages/business/src/index.mjs | 1 + packages/business/src/library-seats.mjs | 51 +++++ .../business/tests/library-seats.test.mjs | 92 ++++++++ packages/control-board/src/serve.mjs | 37 ++- .../tests/creative-hidden.test.mjs | 85 +++++++ packages/control-board/tests/discord.test.mjs | 2 +- packages/control-board/tests/serve.test.mjs | 28 ++- packages/conversation/src/reader.mjs | 63 +++-- packages/conversation/tests/reader.test.mjs | 70 ++++++ packages/discord/src/cli.mjs | 24 +- packages/discord/tests/helpers.mjs | 3 + packages/discord/tests/library-seat.test.mjs | 78 +++++++ packages/webui/tests/discord.test.mjs | 3 +- packages/webui/tests/fixture.mjs | 3 +- packages/webui/tests/history-fixture.mjs | 3 +- packages/webui/tests/relaunch.test.mjs | 3 +- packages/webui/tests/return-flow.test.mjs | 3 +- 19 files changed, 824 insertions(+), 40 deletions(-) create mode 100644 agents/researcher/work/queue-220/BUILD.md create mode 100644 agents/researcher/work/queue-220/r2/BUILD.md create mode 100644 packages/business/src/library-seats.mjs create mode 100644 packages/business/tests/library-seats.test.mjs create mode 100644 packages/control-board/tests/creative-hidden.test.mjs create mode 100644 packages/discord/tests/library-seat.test.mjs diff --git a/agents/researcher/work/queue-220/BUILD.md b/agents/researcher/work/queue-220/BUILD.md new file mode 100644 index 000000000..5d5905132 --- /dev/null +++ b/agents/researcher/work/queue-220/BUILD.md @@ -0,0 +1,100 @@ +# Row 220: C2-10a, no raw session view (M7) and no raw Discord engine (M8) + +Phase A. No test, suite or mutant has been run. Sage opens the slot. + +- Owner: researcher seat (Haiku 5.5 subagent), dispatched by Sage under lead decision 129. +- Reviewer: Filbert. +- Branch: `researcher/row220`, from `origin/refactor` at d37210288. +- Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-10a (line 495) and "Rules every row keeps". +- Design: `agents/gosalyn/work/case2/DESIGN.md` on `origin/gosalyn/case2-design`, sections 5.1 (M7, M8), 7.6, 8 (row list). + +## Status + +| Item | State | +|---|---| +| M7 reader side (`conversation-not-listed`) | Built. Tests written, not run. | +| M7 production wiring (the caller that passes `isCreative`) | Not built. Outside the file list, and it needs a ruling (question 1). | +| M8 `discord check` and `discord run` refusals | Not built. `packages/discord/src/cli.mjs` is untouched. Blocked on question 1 and question 2. | + +Phase A is therefore partial. The row cannot pass its acceptance until Sage rules on the questions below. + +## Files + +- `packages/conversation/src/reader.mjs`: exports `NOT_LISTED`; `createReader` takes `isCreative` (default `() => false`); `catalogue()`, `open()` and `next()` hide a root for which it returns truthy. +- `packages/conversation/tests/reader.test.mjs`: three new tests, inserted before the "unknown conversations" test. +- `agents/researcher/work/queue-220/BUILD.md`: this file. + +Nothing else is changed. `node_modules` in the worktree is a symlink to the canonical checkout and is ignored by git. + +## Brief and design items mapped to tests + +| Item | Test | +|---|---| +| M7: a creative session file under the root isn't listed | "M7: a creative seat's session is not listed and won't open by id; an assistant seat still lists" | +| M7: and won't open by id (`conversation-not-listed`) | same test; second check of the open refusal in "M7: a creative session already open refuses its next page once the root is hidden" | +| M7: an assistant session still lists | same test (the `sage` seat is listed and opens with its text) | +| M7: an already-issued cursor stops once the root is hidden (my addition; the design's "won't open" implies it) | "M7: a creative session already open refuses its next page once the root is hidden" | +| M7: refused rows and the unsupported placeholder of a creative root are hidden too (my addition; the design hides "creative sessions", and a refused row or placeholder names one) | "M7: a creative root's refused files and its unsupported placeholder are hidden as well" | +| Default behavior without `isCreative` is unchanged | the existing reader suite; the default predicate returns false | +| M8: `discord check` / `discord run` refuse `discord-engine-not-allowed` on a library business, and a `mosaic-bus` binding with `tools` | none (not built, question 1) | +| M8: a pi binding on a business without a library still checks clean | none (not built, question 1) | + +Every test's fixture text is `CREATIVE-MARKER` or a made-up assistant line. The M7 tests assert the marker is absent from the catalogue and refusal output. + +## Choices made in phase A + +- `isCreative(root)` is the reader's only input. The reader reads no business file. A truthy answer hides, so a doubtful answer fails closed. The predicate must depend on `root.seat` and `root.project` only, because a cursor is checked against the root object from an earlier listing. +- Hidden check placed after the cursor's actor/purpose/conversation/branch checks in `next()`, and before the source is read. +- `open()` checks hidden after the id resolves, so a creative id gets `conversation-not-listed`, and an unknown id still gets `unknown-conversation`. +- `conversation-not-listed` is `reconcile: false`. The design names no reconcile flag. `unknown-conversation` uses true. Question 3. +- The default predicate is `() => false`. Until a caller passes one, M7 does nothing. This is the open risk, and question 1 covers it. + +## Grep over `packages/*/tests` and `scripts/test-*.sh` (static, phase A) + +Changed path `packages/conversation/src/reader.mjs`: +- `packages/conversation/tests/reader.test.mjs:14` imports `rootsFromSpecs, createReader, conversationId, cwdInProject, UNSUPPORTED_HARNESS`. It is the file under change. +- `packages/control-board/tests/serve.test.mjs:26` imports `UNSUPPORTED_HARNESS`. That export is unchanged. +- `packages/library/tests/sync.test.mjs:15` imports `packages/library/src/reader.mjs`, a different file. Not a hit. +- `scripts/test-*.sh`: no reference to the conversation reader. +- 64-hex pins naming this path: none. `packages/bus/tests/grant-migration.test.mjs:24` has a 64-hex value, but it is a DDL digest, not a pin on this path. That file matched only the word "reader" in a test title. + +Changed path `packages/conversation/tests/reader.test.mjs`: +- No other test or script names this file. It runs through `packages/conversation`'s `node --test tests/`. +- No 64-hex value in the file. + +Changed path `agents/researcher/work/queue-220/BUILD.md`: no pin. + +`node --check` passed on both changed `.mjs` files. + +## Phase B plan (not run) + +The reader test uses `os.tmpdir()`. Set `TMPDIR` to a scratch dir first, per the rule against `/tmp`. + +``` +export TMPDIR="$(mktemp -d /mnt/storage/scratch/tmp/r220XXXXXX)" +cd packages/conversation && node --test tests/reader.test.mjs +cd packages/conversation && node --test tests/ +cd packages/discord && node --test tests/ +cd packages/control-board && node --test tests/serve.test.mjs +``` + +The first command is the touched file whole. The second and third are the conversation and discord suites whole, as the brief asks. The control-board serve test is not in the brief. It consumes `reader.mjs`, so I recommend it. Sage can drop it. + +## Questions for Sage + +1. **Business link (blocks M8 and the M7 wiring).** Neither rule can be built from this row's file list, because the inputs don't exist in the code: + - A reader root carries `seat` (the spec's `agent`) and `project` (the repo directory name). It has no business id. Roots come from control-board scan specs (`packages/control-board/src/scan.mjs`, `discoverRepoAgents`) and seat registrations. Neither carries a definition. + - A Discord binding has `seat` and no business key (`packages/discord/src/binding.mjs`, `TOP_KEYS`). + - Creative status lives only in the business file: `roles..definition` in `CREATIVE_DEFINITIONS` (`packages/business/src/vocabulary.mjs:176`). A library project is `projects..kind === "library"` (`packages/business/src/business.mjs:243`). + - Creative thread sessions are stored at `/threads///session` (`packages/seat/src/session.mjs:59-64`), outside the `.pi/state` roots the board scans. M7 may therefore reach only creative seats that have a `.pi/state` session root. Please confirm. + - The design does not define the mapping from a seat to a business. + + Recommendation: add a required `business` key to the binding, validated against `businesses/.json`. The board builds `isCreative` from `loadBusiness(id)`: a root is creative when its seat is a `roles` key whose definition is in `CREATIVE_DEFINITIONS`, and the business has a library project. This needs file scope for `packages/discord/src/binding.mjs` and `cli.mjs` (M8), and `packages/control-board/src/serve.mjs` (M7 wiring). None of those is on this row's list. I need that scope amendment and the mapping ruling before I build M8 or the wiring. + +2. **M8 refusal code for a `mosaic-bus` binding with `tools`.** The design names `discord-engine-not-allowed` for the engine case and no code for the tools case. A new code goes to you first. My proposal is to reuse `discord-engine-not-allowed` with a message that names the tools key. + +3. **`reconcile` on `conversation-not-listed`.** I chose `false`. Confirm or change. + +4. **HTTP status for `conversation-not-listed`.** `REFUSAL_STATUS` in `packages/control-board/src/serve.mjs` has no entry, so the route returns 422. My proposal is 404, next to `unknown-conversation`. This needs that file on the scope list too. + +Phase A stops here. On your "slot clear" I will run the phase B commands above. I will not build M8 or the wiring until question 1 is answered. diff --git a/agents/researcher/work/queue-220/r2/BUILD.md b/agents/researcher/work/queue-220/r2/BUILD.md new file mode 100644 index 000000000..29ca0d9de --- /dev/null +++ b/agents/researcher/work/queue-220/r2/BUILD.md @@ -0,0 +1,215 @@ +# Row 220 r2: phase A round 2 (M7, M8, business helper, REFUSAL_STATUS) + +Phase A only. No node, npm, `node --check` or test run has happened in this round, and nothing is +committed. Phase B (the suites) waits for the coordinator's "slot clear". + +- Owner: researcher seat (Haiku 5.5 subagent), dispatched by Sage under lead decision 129. +- Reviewer: Filbert. +- Branch: `researcher/row220-r2`, from `origin/refactor` at da9555ca2. The r1 cherry-pick 3fa833715 is + staged with `-n`. +- Rulings: `agents/sage/work/queue-220/RULINGS.md` on origin/refactor (da9555ca2). Import-by-path is + followed: no export line in `packages/business/src/index.mjs`, and `packages/discord/src/binding.mjs` + is untouched. + +## Coordinator rulings, applied + +Round 1 answers (earlier packet): applied as before. Absent `businesses/` gives empty sets only on +ENOENT; other failures throw. stack-copy test joins phase B. Webui fixtures pass `businessDir`. Discord +refusals exit 2. + +Round 2 rulings (coordinator message, this round): + +1. **Dangling symlink at `businesses/` refuses.** `loadSeatSets` calls `lstatSync(join(dir, "businesses"))` + first. Only an lstat ENOENT (nothing at the path) returns empty sets. If something is there and + `readdirSync` fails for any reason, including ENOENT through a dangling link, it throws `BusinessError`. + Test added: "a businesses symlink to a missing target throws; no empty set". The absent-directory test is kept. +2. **Sets are read once per public reader call.** `createReader` takes `creativeCheck`, a factory. Each + `catalogue()`, `open()` and `next()` calls it once, then applies the returned predicate to every root. + A load failure refuses `conversation-business-unreadable` for the whole call. `serve.mjs` exports + `creativeCheck(dir = configDir())`, the factory, and `createServer` passes `creativeCheck(businessDir)`. + Test added: "one catalogue calls the creative check once, however many roots it lists". It counts factory + calls: 1 after `catalogue()`, 2 after one `open()`. +3. **Phase B adds three test files:** `packages/webui/tests/tester-browser.test.mjs`, + `tester-cli.test.mjs` and `proof-cli.test.mjs`, run whole (they reach `fixture.mjs` through + `tester-fixture.mjs`). Phase B is 36 files: 33 plus these 3. +4. **Scope additions approved** (coordinator round 2 ruling 4): `packages/control-board/tests/discord.test.mjs` + line 123 and the five webui test files (`fixture.mjs`, `history-fixture.mjs`, `relaunch.test.mjs`, + `return-flow.test.mjs`, `discord.test.mjs`). They stop tests reading the real config directory. +5. **TMPDIR** for phase B is set by the coordinator under `/mnt/storage/scratch/tmp`. No code change. + +## Discord exit code + +Refusals from the library-seat check exit 2, the code the Discord CLI already uses for configuration and +binding refusals. + +- `packages/discord/src/errors.mjs` line 2: `// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or configuration, 3 refused by a brake (STOP or a held binding; a supervisor must not retry), 4 usage.` +- `packages/discord/src/errors.mjs` line 5: `constructor(message, exitCode = 2, details = undefined)`. +- Config refusal precedent: `packages/discord/src/binding.mjs` line 67, `throw new DiscordError(\`config.dataRoot must be an absolute path: ${path}\`);`. No explicit code, so exit 2. +- `checkLibraryEngine` in `packages/discord/src/cli.mjs` lines 115 to 122. Each refusal passes `, 2)`: + `discord-business-unreadable` (115), `discord-engine-not-allowed` for a non-mosaic-bus engine (119), + and `discord-engine-not-allowed` for a mosaic-bus binding that declares `tools` (122). +- `grep -n ", 3)" packages/discord/src/cli.mjs` finds nothing. `grep -rn "status, 3" packages/discord/tests` + finds only existing brake tests in recover.test.mjs and binding.test.mjs. Not changed. + `library-seat.test.mjs` asserts 2 on all three refusals. + +## Files (round 2, uncommitted) + +| File | Change | +|---|---| +| `packages/business/src/library-seats.mjs` | new: `loadSeatSets({ dir, rolesDir })` returns `{ librarySeats, creativeSeats }`; lstat-first (ruling 1); read per call | +| `packages/business/tests/library-seats.test.mjs` | new: 7 tests (6 from r1 plus the dangling symlink) | +| `packages/conversation/src/reader.mjs` | r1 plus r2: `NOT_LISTED`; `creativeCheck` factory option, called once per `catalogue()`, `open()` and `next()`; `catalogue()` wrapped so a refusal returns `{ ok: false }` | +| `packages/conversation/tests/reader.test.mjs` | r1: 3 M7 tests, now on `creativeCheck`; r2: 1 spy test (one factory call per catalogue) | +| `packages/control-board/src/serve.mjs` | `REFUSAL_STATUS`: `conversation-not-listed` 404, `conversation-business-unreadable` 503; `export creativeCheck(dir = configDir())` (factory); `createServer({ businessDir = configDir() })` wires `creativeCheck: creativeCheck(businessDir)` | +| `packages/control-board/tests/creative-hidden.test.mjs` | new: 4 tests (3 from r1 plus known-and-unknown id both 503), switched to `creativeCheck` | +| `packages/control-board/tests/serve.test.mjs` | pin test adds `NOT_LISTED` and `conversation-business-unreadable`; conversation fixture passes `businessDir` | +| `packages/control-board/tests/discord.test.mjs` | line 123 passes `businessDir` (ruling 4) | +| `packages/discord/src/cli.mjs` | `checkLibraryEngine` called from `prepare()`; exit 2 | +| `packages/discord/tests/helpers.mjs` | `makeDeployment` creates an empty `businesses/` beside `config.json` | +| `packages/discord/tests/library-seat.test.mjs` | new: 5 tests, all `--offline`, all asserting exit 2 | +| `packages/webui/tests/fixture.mjs` | `businessDir` on the board start (ruling 4) | +| `packages/webui/tests/history-fixture.mjs` | `businessDir` on the board start (ruling 4) | +| `packages/webui/tests/relaunch.test.mjs` | `businessDir` on the board start (ruling 4) | +| `packages/webui/tests/return-flow.test.mjs` | `businessDir` on the board start (ruling 4) | +| `packages/webui/tests/discord.test.mjs` | `businessDir` on the board start (ruling 4) | + +Not touched: `packages/business/src/index.mjs`, `packages/discord/src/binding.mjs`, `scripts/stack-copy.sh`. + +## Brief item to test map + +| Brief item | Test | +|---|---| +| Q1 helper: library and creative sets from business files | `business/tests/library-seats.test.mjs` "library seats come from library businesses..." | +| Q1 a non-library business puts no seat in either set | `library-seats.test.mjs` "a business with no library project..." | +| Q1 absent businesses directory gives empty sets | `library-seats.test.mjs` "an absent businesses directory gives empty sets" | +| Q1 any other failure throws, no empty set | `library-seats.test.mjs` "a businesses path that is not a directory throws"; "a businesses symlink to a missing target throws"; "a business file that can't be read throws"; "a business file that is not valid JSON throws" | +| Round 2 ruling 2: one read per public call | `conversation/tests/reader.test.mjs` "one catalogue calls the creative check once..." | +| M7 hidden from catalogue, open and next | `control-board/tests/creative-hidden.test.mjs` "the check hides the creative seat..."; r1 reader tests (catalogue, open, next, symlink and placeholder) | +| M7 second wall: thread store | `creative-hidden.test.mjs` "a thread store is not a root from a spec..." (asserts `rootsFromSpecs` drops it, then a root over it is hidden with 404 `conversation-not-listed`) | +| M7 unreadable business refuses listing and open | `creative-hidden.test.mjs` "an unreadable business file refuses the listing and open with 503" | +| Coordinator follow-up: known and unknown id both 503 when unreadable | `creative-hidden.test.mjs` "with an unreadable business file, a known and an unknown id both refuse with 503" | +| M8 non-mosaic-bus engine on a library seat | `discord/tests/library-seat.test.mjs` "a library seat's binding on a non-mosaic-bus engine is refused" (exit 2, message names the engine) | +| M8 mosaic-bus binding that declares tools | `library-seat.test.mjs` "...declares tools is refused, and the message names tools" (exit 2) | +| M8 mosaic-bus, no tools, allowed | `library-seat.test.mjs` "...with no tools passes the engine rule" | +| M8 unreadable business file | `library-seat.test.mjs` "an unreadable business file refuses check with discord-business-unreadable" (exit 2) | +| M8 seat in no library business checks clean | `library-seat.test.mjs` "a binding on a seat that is in no library business checks clean" | +| Q3 reconcile false | r1 reader test asserts `[false, "conversation-not-listed", false]` | +| Q4 HTTP 404 and 503 | `creative-hidden.test.mjs` (status asserted); `serve.test.mjs` pin test | + +## Choices (for Filbert to check) + +1. **Absent versus unreadable `businesses/`.** Only nothing at the path reads as empty (lstat ENOENT). A + dangling symlink, a file, or an unreadable directory throws. Coordinator ruling 1. +2. **Sets are read once per public call.** `creativeCheck()` runs once in each of `catalogue()`, `open()` and + `next()`. The returned predicate answers every root in that call. A catalogue with zero roots still + loads the sets once, so an unreadable business file refuses even an empty list. Coordinator ruling 2. +3. **Behavior change, kept on coordinator ruling (round 2 follow-up): an unknown id or cursor loads the sets + first.** In `open()`, the check runs after `checkCaller` and before `resolveConversation`. In `next()`, it + runs at the start of the try. So with an unreadable business file, a known id and an unknown id both give + 503 `conversation-business-unreadable`, where round 1 gave 404 `unknown-conversation` for the unknown id. + Checking the id first would give 503 for a hidden creative id and 404 for an unknown one, which reveals + whether the hidden id exists. Loading first answers every id the same way (fail closed). This reverses + round 1 choice 3. Test: `creative-hidden.test.mjs` "with an unreadable business file, a known and an unknown id both refuse with 503". +4. **Discord refusals use the `code: message` form and exit 2.** Exit 3 is for brake refusals (STOP or a held + binding). A seat's engine or library membership is not a brake. The business CLI is not used as precedent. +5. **The thread-store wall exists in part already.** `rootsFromSpecs` (`reader.mjs` line 44) drops any + sessions directory that is not `/.pi/state//sessions`. The M7 check is the second wall, for + roots built another way. The test checks both. + +## Static checks (read-only; no node) + +- **COPY_PATHS** (`scripts/stack-copy.sh` 29 to 41): `packages/business/src`, `packages/conversation/src`, + `packages/discord/src` and `packages/library/src` are present. `packages/control-board/src` is not. COPY_PATHS + is not changed. +- **Stale names.** `grep -rn -E "isCreative|creativeRootPredicate" packages scripts` finds nothing after this round's edits. +- **Board starts without `businessDir`.** Webui: all five direct starts pass it. Control board: + - `serve.test.mjs` has seven single-line starts without it (lines 402, 445, 464, 483, 509, 528, 907) and four + multi-line starts without it (114, 149, 229, 258). None of those tests reaches a conversation route. + Every conversation route call in the file (lines 1027 to 1171) follows a start that passes `businessDir` + (1006, 1075, 1124). Checked by line order. + - `creative-hidden.test.mjs` builds `creativeCheck(f.dir)` on its own scratch directory. No default is used. + - `discord.test.mjs` passes `businessDir` (line 123). +- **Consumers of the two fixtures** (exact import strings, `from './fixture.mjs'`, `from './history-fixture.mjs'`): + - Direct `fixture.mjs` importers, 31 test files: board-held, browser-edge, browser, bus-browser, bus, + chat-browser, chat, discord, docs-browser, editor-browser, editor, instances-browser, limits-browser, + limits-route, messages, preview, proof, relaunch, return-flow, routes-browser, send-browser, send, serve, + settings-form, shell, sso-browser, sso, tester-listener, usage-browser, usage, views. + - `history-fixture.mjs` importers, 2 test files: conversation, history-return-flow. + - Total fixture consumers: 33. Plus `tester-browser`, `tester-cli` and `proof-cli` (through + `tester-fixture.mjs`, ruling 3): 36 files in phase B. + +## Phase B commands (after "slot clear" only) + +TMPDIR is set by the coordinator under `/mnt/storage/scratch/tmp`. The commands below assume it. The new +tests call `tmpdir()`, so an unset TMPDIR would put them under `/tmp`, which bwrap hides. + +```sh +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/conversation && node --test tests/ +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/discord && node --test tests/ +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/business && node --test tests/ +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/control-board && node --test tests/ +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e && scripts/test-discord.sh +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/cli && node --test tests/stack-copy.test.mjs +cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a1b36cb7769b3d00e/packages/webui && node --test tests/board-held.test.mjs tests/browser-edge.test.mjs tests/browser.test.mjs tests/bus-browser.test.mjs tests/bus.test.mjs tests/chat-browser.test.mjs tests/chat.test.mjs tests/conversation.test.mjs tests/discord.test.mjs tests/docs-browser.test.mjs tests/editor-browser.test.mjs tests/editor.test.mjs tests/history-return-flow.test.mjs tests/instances-browser.test.mjs tests/limits-browser.test.mjs tests/limits-route.test.mjs tests/messages.test.mjs tests/preview.test.mjs tests/proof.test.mjs tests/relaunch.test.mjs tests/return-flow.test.mjs tests/routes-browser.test.mjs tests/send-browser.test.mjs tests/send.test.mjs tests/serve.test.mjs tests/settings-form.test.mjs tests/shell.test.mjs tests/sso-browser.test.mjs tests/sso.test.mjs tests/tester-listener.test.mjs tests/usage-browser.test.mjs tests/usage.test.mjs tests/views.test.mjs tests/tester-browser.test.mjs tests/tester-cli.test.mjs tests/proof-cli.test.mjs +``` + +The webui line is 36 files, run whole. `history-fixture.mjs` and `tester-fixture.mjs` run through their +consumers. No test has been run. The expectations above come from reading the code. + +## Open items + +a. Phase B checks the dangling-symlink test on this platform (lstat then readdir ENOENT must throw). +b. Phase B checks the spy count (1 after catalogue, 2 after one open). A failure here means a public call + reads the factory more than once. +c. Choice 3 is settled by the coordinator: keep 503 for unknown ids too. Phase B checks the new known-and-unknown test. +d. The browser-based webui tests need a browser in this environment. A failure there would be environmental. + Phase B will show which. +e. Nothing here has been executed. Phase B checks every expectation, including the unreadable-file test + (EACCES must throw), the root skip (uid 0 skips), and the webui consumers. + +## Resolved + +- Dangling symlink at `businesses/` (round 1 open item a, and round 1 question 1): refuses, ruling 1. +- Tester and proof CLI tests (round 1 question 2): join phase B, ruling 3. +- Per-root reads (round 1 open item b): replaced by one read per public call, ruling 2. + +## Rebase onto aca16404c, run limits, index export (2026-10-22, sage-2708) + +Added after the text above; nothing above is edited. "Nothing is committed" above was true when written. + +- Commit `6f656abac` on `researcher/row220-r2`, one commit, rebased from da9555ca2 onto origin/refactor + `aca16404c`. Row 253 has not landed; this rebases again onto 253's tip before any gate. +- Drift check, da9555ca2..aca16404c against my 18 paths: one overlap, `packages/discord/src/cli.mjs` + (the processing-control imports, rows 264 and neighbours). The conflict was the import block only. I + kept both sides: the two landed `processing-control` imports and my `loadSeatSets` import. No landed hunk + is reverted. `checkLibraryEngine` and its call in `prepare()` merged without a marker. +- Board starts: no test added since da9555ca2 starts the control board or reads a conversation route + without `businessDir`. `packages/webui/tests/shell.test.mjs` fakes the board and does not start it. +- Two edits since the r2 text: (1) a missing space in `reader.mjs` `next()` (`held =typeof`) fixed; + (2) `packages/business/src/index.mjs` gets `export { loadSeatSets } from "./library-seats.mjs";`. The text + above says "no export line". RULINGS.md Q1 says the helper is "exported from the package index" and the + scope amendment lists "its export line", so the file wins and the export is added. The two callers still + import by path, so nothing needs the export today. Filbert: drop the line if you read the ruling otherwise. + The export adds no test; `node --check` has not been run on it (no node before the CLEAR). + +### Run limits (fixed before any run) + +Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE`; nothing runs unbounded. + +- Node test files: LIMIT 900 s. I found no recorded per-file time for the conversation, discord, business, + control-board or webui files. 900 s is above the longest recorded whole-package run I know of (cli, 591 s, + row 281 BUILD.md "Runs" step 3), so it holds the 3x rule for any single file. The first run records each + file's time; if a file passes 300 s, the limit is re-derived from it before the next run. +- Shell scripts: `timeout -k 10 LIMIT bash scripts/test-X.sh`, LIMIT = 3x the longest recorded run plus + 120 s, taken from Megavolt's row 281 table (`agents/megavolt/work/queue-281/BUILD.md`, from Gander's + `agents/gander/work/queue-278/SHELL-LIMITS.json`). A script with no limit does not run. + +| Script | LIMIT | Script | LIMIT | +|---|---|---|---| +| test-auth | 126 s | test-land-work | 158 s | +| test-conductor | 145 s | test-queue | 191 s | +| test-config | 130 s | test-release | 157 s | +| test-discord | 147 s | test-task | 231 s | +| test-extension-package | 130 s | test-foundation | 240 s | + +Any timeout goes to Sage, then Mos, unplaced (mos-1275). diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs index 50be671b4..3872d22a2 100644 --- a/packages/business/src/index.mjs +++ b/packages/business/src/index.mjs @@ -13,3 +13,4 @@ export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } f export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs"; export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs"; export { systemVars, resolveInstance, classify } from "./resolve.mjs"; +export { loadSeatSets } from "./library-seats.mjs"; diff --git a/packages/business/src/library-seats.mjs b/packages/business/src/library-seats.mjs new file mode 100644 index 000000000..7b4474010 --- /dev/null +++ b/packages/business/src/library-seats.mjs @@ -0,0 +1,51 @@ +// Case 2 design 5.1 (M7, M8, row 220): which seats work in a library business, +// and which of those hold a creative definition. Read from the business files on +// every call, so an edit takes effect without a restart. Fail closed: a business +// file that can't be read or validated throws. So does anything at the businesses +// path that can't be listed: a file, a dangling symlink, or a directory that can't +// be read. Only a path with nothing at it (lstat ENOENT) holds no library business, +// so both sets are empty. No caller turns a throw into an empty set. +import { lstatSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BusinessError } from "./errors.mjs"; +import { configDir, loadBusiness } from "./business.mjs"; +import { CREATIVE_DEFINITIONS } from "./vocabulary.mjs"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +export function defaultRolesDir(env = process.env) { + return resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")); +} + +// Returns { librarySeats, creativeSeats }: Sets of seat names (the role keys of +// each business). A seat in two businesses counts when either one puts it in a +// set. creativeSeats is a subset of librarySeats. +export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) { + // lstat first: only nothing at the path is an absent directory. Anything else + // there (a file, a dangling symlink, a directory) must list, or it throws. + const businesses = join(dir, "businesses"); + try { + lstatSync(businesses); + } catch (err) { + if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() }; + throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`); + } + let names; + try { + names = readdirSync(businesses).filter((name) => name.endsWith(".json")); + } catch (err) { + throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`); + } + const librarySeats = new Set(); + const creativeSeats = new Set(); + for (const name of names.sort()) { + const business = loadBusiness(name.slice(0, -".json".length), { dir, rolesDir }); + if (!Object.values(business.projects).some((project) => project.kind === "library")) continue; + for (const [seat, role] of Object.entries(business.roles)) { + librarySeats.add(seat); + if (CREATIVE_DEFINITIONS.includes(role.definition)) creativeSeats.add(seat); + } + } + return { librarySeats, creativeSeats }; +} diff --git a/packages/business/tests/library-seats.test.mjs b/packages/business/tests/library-seats.test.mjs new file mode 100644 index 000000000..de8d44057 --- /dev/null +++ b/packages/business/tests/library-seats.test.mjs @@ -0,0 +1,92 @@ +// Case 2 M7/M8 (design 5.1, row 220): loadSeatSets reads every business file in +// the config directory. A seat is a library seat when a business with a library +// project names it; a creative seat is a library seat whose definition is creative. +// Any file it can't read or validate throws, and so does a businesses path that is +// not a directory. An absent businesses directory gives empty sets. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadSeatSets } from "../src/library-seats.mjs"; +import { BusinessError } from "../src/index.mjs"; +import { businessDoc, writeJson, REPO_ROLES } from "./helpers.mjs"; +import { mappingEntry } from "../../library/tests/helpers.mjs"; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "row220-seats-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const dir = join(root, "config"); + mkdirSync(join(dir, "businesses"), { recursive: true }); + return { root, dir }; +} + +const sets = (dir) => loadSeatSets({ dir, rolesDir: REPO_ROLES }); + +test("M7/M8: library seats come from library businesses, creative seats from their creative definitions", (t) => { + const { root, dir } = fixture(t); + const doc = businessDoc(root, "acme"); + doc.projects = { books: mappingEntry(), ...doc.projects }; + doc.roles.bean = { definition: "companion" }; + writeJson(join(dir, "businesses", "acme.json"), doc); + const found = sets(dir); + assert.deepEqual([...found.librarySeats].sort(), ["bean", "chief", "coder", "cto", "pm", "reviewer"]); + assert.deepEqual([...found.creativeSeats], ["bean"]); + assert.ok([...found.creativeSeats].every((seat) => found.librarySeats.has(seat))); +}); + +test("M8: a business with no library project puts no seat in either set", (t) => { + const { root, dir } = fixture(t); + const doc = businessDoc(root, "other"); + doc.roles.bean = { definition: "companion" }; + writeJson(join(dir, "businesses", "other.json"), doc); + const found = sets(dir); + assert.equal(found.librarySeats.size, 0); + assert.equal(found.creativeSeats.size, 0); +}); + +test("M7/M8: a business file that is not valid JSON throws; no empty set", (t) => { + const { root, dir } = fixture(t); + writeJson(join(dir, "businesses", "acme.json"), businessDoc(root, "acme")); + const broken = join(dir, "businesses", "broken.json"); + writeFileSync(broken, "{"); + chmodSync(broken, 0o600); + assert.throws(() => sets(dir), BusinessError); +}); + +test("M7/M8: an absent businesses directory gives empty sets", (t) => { + const root = mkdtempSync(join(tmpdir(), "row220-seats-none-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const found = sets(join(root, "config")); + assert.equal(found.librarySeats.size, 0); + assert.equal(found.creativeSeats.size, 0); +}); + +test("M7/M8: a businesses path that is not a directory throws; no empty set", (t) => { + const root = mkdtempSync(join(tmpdir(), "row220-seats-file-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const dir = join(root, "config"); + mkdirSync(dir); + writeFileSync(join(dir, "businesses"), "not a directory"); + assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /cannot list businesses/.test(err.message)); +}); + +test("M7/M8: a businesses symlink to a missing target throws; no empty set", (t) => { + const root = mkdtempSync(join(tmpdir(), "row220-seats-dangling-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const dir = join(root, "config"); + mkdirSync(dir); + symlinkSync(join(root, "missing"), join(dir, "businesses")); + assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /cannot list businesses/.test(err.message)); +}); + +test("M7/M8: a business file that can't be read throws; no empty set", (t) => { + const { root, dir } = fixture(t); + writeJson(join(dir, "businesses", "acme.json"), businessDoc(root, "acme")); + const file = join(dir, "businesses", "acme.json"); + chmodSync(file, 0o000); + t.after(() => chmodSync(file, 0o600)); + // Root reads a mode 000 file, so the check cannot hold there. + if (process.getuid?.() === 0) return t.skip("running as root: mode 000 files stay readable"); + assert.throws(() => sets(dir)); +}); diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 655ac6208..2fcf662ac 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -48,6 +48,9 @@ import { hostname } from "node:os"; import { spawnSync } from "node:child_process"; import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs"; import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; +import { Refusal } from "../../conversation/src/safe-fs.mjs"; +import { loadSeatSets } from "../../business/src/library-seats.mjs"; +import { configDir } from "../../business/src/business.mjs"; const MAX_BODY = 4096; @@ -170,6 +173,8 @@ export function foreignRequest(req) { export const REFUSAL_STATUS = { "unknown-conversation": 404, "unknown-branch": 404, + "conversation-not-listed": 404, + "conversation-business-unreadable": 503, unavailable: 404, "cursor-unknown": 409, "cursor-expired": 409, @@ -210,7 +215,8 @@ function conversationQuery(url) { export function conversationResponse(reader, url) { if (url.pathname === "/api/conversations") { if ([...url.searchParams.keys()].length) return { status: 400, body: { error: "no parameters are accepted" } }; - return { status: 200, body: reader.catalogue() }; + const listing = reader.catalogue(); + return listing.ok ? { status: 200, body: listing } : refusalResponse(listing); } const query = conversationQuery(url); if (query.error) return { status: 400, body: { error: query.error } }; @@ -218,17 +224,42 @@ export function conversationResponse(reader, url) { ? reader.next({ cursor: query.cursor, conversation: query.id, branch: query.branch }) : reader.open({ conversation: query.id, branch: query.branch ?? null }); if (out.ok) return { status: 200, body: out }; + return refusalResponse(out); +} + +function refusalResponse(out) { return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } }; } +// Case 2 M7 (design 5.1): a root whose seat is creative in a library business is +// hidden from the catalogue and from open and next. creativeCheck(dir) returns the +// factory the reader calls once per public call: each call reads the business sets +// once and applies that one answer to every root, so an edited business file takes +// effect on the next call without a restart. A business file that can't be read +// refuses the whole call with conversation-business-unreadable. +export function creativeCheck(dir = configDir()) { + return () => { + let sets; + try { + sets = loadSeatSets({ dir }); + } catch (err) { + throw new Refusal("conversation-business-unreadable", `a business file cannot be read: ${err.message}`); + } + return (root) => sets.creativeSeats.has(root.seat); + }; +} + export function loadPage(path = join(import.meta.dirname, "page.html")) { return readFileSync(path, "utf8"); } // specs: agent specs to scan on each request. boardDir: where scan writes. -export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) { +export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null, businessDir = configDir() }) { const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); - const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) }); + const reader = conversationReader ?? createReader({ + roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations), + creativeCheck: creativeCheck(businessDir), + }); return createHttpServer((req, res) => { const refused = foreignRequest(req); if (refused) { diff --git a/packages/control-board/tests/creative-hidden.test.mjs b/packages/control-board/tests/creative-hidden.test.mjs new file mode 100644 index 000000000..aa66131b2 --- /dev/null +++ b/packages/control-board/tests/creative-hidden.test.mjs @@ -0,0 +1,85 @@ +// Case 2 M7 (design 5.1, row 220): a creative seat's conversations are hidden from +// the catalogue, from open and from next. The thread store is a second wall: a +// root built over it is hidden, and no spec under .pi/state reaches it. A business +// file the board can't read refuses the listing and open, never a partial list. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { conversationResponse, creativeCheck, REFUSAL_STATUS } from "../src/serve.mjs"; +import { conversationId, createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"; +import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; +import { mappingEntry } from "../../library/tests/helpers.mjs"; + +// A config directory holding one library business whose seat "bean" is creative, +// and a thread store for bean's session under /threads//. +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "row220-board-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const dir = join(root, "config"); + const doc = businessDoc(root, "acme"); + doc.projects = { books: mappingEntry(), ...doc.projects }; + doc.roles.bean = { definition: "companion" }; + writeJson(join(dir, "businesses", "acme.json"), doc); + const threadDir = join(root, "data", "threads", "acme", "t1", "session"); + mkdirSync(threadDir, { recursive: true }); + writeFileSync(join(threadDir, "s.jsonl"), "{}\n"); + const threadRoot = { seat: "bean", project: "acme", projectRoot: join(root, "data"), dir: threadDir, harness: "pi", unsupportedReason: null, engineStartedAt: null }; + return { root, dir, threadDir, threadRoot }; +} + +function breakBusinessFile(dir) { + const file = join(dir, "businesses", "broken.json"); + writeFileSync(file, "{"); + chmodSync(file, 0o600); +} + +const detail = (id) => new URL(`/api/conversation?id=${encodeURIComponent(id)}`, "http://localhost"); +const listing = new URL("/api/conversations", "http://localhost"); + +test("M7: the check hides the creative seat, shows the others, and refuses when a business file is unreadable", (t) => { + const f = fixture(t); + const hidden = creativeCheck(f.dir)(); + assert.equal(hidden({ seat: "bean" }), true); + assert.equal(hidden({ seat: "pm" }), false); + breakBusinessFile(f.dir); + assert.throws(() => creativeCheck(f.dir)(), (err) => err.code === "conversation-business-unreadable"); +}); + +test("M7: a thread store is not a root from a spec, and a root built over it is hidden from the list and from open", (t) => { + const f = fixture(t); + // The spec layout check in rootsFromSpecs drops a thread-store directory. + assert.deepEqual(rootsFromSpecs([{ agent: "bean", project: "acme", sessionsDir: f.threadDir }]), []); + const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); + assert.deepEqual(reader.catalogue().conversations, []); + const out = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); + assert.equal(out.status, REFUSAL_STATUS["conversation-not-listed"]); + assert.equal(out.status, 404); + assert.equal(out.body.refusal.code, "conversation-not-listed"); +}); + +test("M7: with an unreadable business file, a known and an unknown id both refuse with 503", (t) => { + const f = fixture(t); + breakBusinessFile(f.dir); + const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); + const known = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); + const unknown = conversationResponse(reader, detail(conversationId(f.threadRoot, "missing.jsonl"))); + for (const out of [known, unknown]) { + assert.equal(out.status, 503); + assert.equal(out.body.refusal.code, "conversation-business-unreadable"); + } +}); + +test("M7: an unreadable business file refuses the listing and open with 503, never a partial list", (t) => { + const f = fixture(t); + breakBusinessFile(f.dir); + const reader = createReader({ roots: [f.threadRoot], creativeCheck: creativeCheck(f.dir) }); + const cat = conversationResponse(reader, listing); + assert.equal(cat.status, 503); + assert.equal(cat.body.refusal.code, "conversation-business-unreadable"); + assert.equal(cat.body.conversations, undefined); + const opened = conversationResponse(reader, detail(conversationId(f.threadRoot, "s.jsonl"))); + assert.equal(opened.status, REFUSAL_STATUS["conversation-business-unreadable"]); + assert.equal(opened.status, 503); +}); diff --git a/packages/control-board/tests/discord.test.mjs b/packages/control-board/tests/discord.test.mjs index 9b69d31bf..e50a0ec96 100644 --- a/packages/control-board/tests/discord.test.mjs +++ b/packages/control-board/tests/discord.test.mjs @@ -120,7 +120,7 @@ test('server rescans connector discovery and refuses HTTP reply without transpor try { const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats'); writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid })); - server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); + server = await startServer({ port: 0, specs: [], seatsDir, businessDir: join(f.root, 'businesses'), boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); const base = `http://127.0.0.1:${server.address().port}`; let response = await fetch(base + '/api/board'); const board = await response.json(); assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true); diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index ef7ef549d..b5f5c49cd 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -23,7 +23,7 @@ import { request as httpRequest } from "node:http"; import { ConfigError, markSeen } from "../src/scan.mjs"; import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER, REFUSAL_STATUS } from "../src/serve.mjs"; import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs"; -import { UNSUPPORTED_HARNESS } from "../../conversation/src/reader.mjs"; +import { UNSUPPORTED_HARNESS, NOT_LISTED } from "../../conversation/src/reader.mjs"; const pkgRoot = resolve(import.meta.dirname, ".."); const cli = join(pkgRoot, "src", "cli.mjs"); @@ -736,7 +736,7 @@ async function postReply(base, body, headers = { "content-type": "application/js test("POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") }); const base = `http://127.0.0.1:${server.address().port}`; process.env.MOSAIC_TMUX_SOCKET = "leaked-from-launcher"; delete process.env.FAKE_SEND_EXIT; @@ -768,7 +768,7 @@ test("POST /api/reply: runs agent-send.sh with -s from the registration, -S ", async () => { const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } }); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" }); @@ -783,7 +783,7 @@ test("POST /api/reply: a registration with a tmux socket adds -L ", asyn test("POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); const base = `http://127.0.0.1:${server.address().port}`; process.env.FAKE_SEND_EXIT = "4"; process.env.FAKE_SEND_STDERR = "agent-send.sh: REFUSING - session 'agent1' exists on multiple sockets\n"; @@ -814,7 +814,7 @@ test("POST /api/reply: refusals before the tool runs: empty or blank or long tex ]; for (const c of cases) { const f = c.fixture; - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend, isPidAlive: c.isPidAlive }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend, isPidAlive: c.isPidAlive }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, c.body); @@ -829,7 +829,7 @@ test("POST /api/reply: refusals before the tool runs: empty or blank or long tex test("POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: join(f.root, "missing-agent-send.sh") }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: join(f.root, "missing-agent-send.sh") }); const base = `http://127.0.0.1:${server.address().port}`; try { const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" }); @@ -964,7 +964,7 @@ function rawRequest(port, { method = "GET", path = "/api/board", headers = {}, b test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); const port = server.address().port; const own = `127.0.0.1:${port}`; const reply = JSON.stringify({ agent: "proj/agent1", text: "rebound page" }); @@ -1003,7 +1003,7 @@ test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Hos test("Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin", async () => { const f = replyFixture(); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", agentSend: f.agentSend }); const port = server.address().port; delete process.env.FAKE_SEND_EXIT; delete process.env.FAKE_SEND_STDERR; @@ -1049,7 +1049,9 @@ function conversationFixture() { resolved: { seat: "rocko", project: "proj", sessionsDir: join(proj, ".pi", "state", "rocko", "sessions"), seatDir: join(root, "rocko"), launchScript: join(root, "rocko", "launch.sh"), layout: "repo", defaultWorkspace: null }, harness: "claude-code", })); - return { root, proj, sessionsDir, seatsDir, boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] }; + // Row 220 M7: the default reader reads the businesses beside this fixture, never the real config. + mkdirSync(join(root, "businesses"), { recursive: true }); + return { root, proj, sessionsDir, seatsDir, businessDir: join(root, "businesses"), boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] }; } // Size, sha256, mtime, (dev, ino) and listing of every entry under dir. @@ -1070,7 +1072,7 @@ test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin const f = conversationFixture(); const calls = []; const spy = { catalogue: () => calls.push("catalogue"), open: () => calls.push("open"), next: () => calls.push("next") }; - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", conversationReader: spy }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "", conversationReader: spy }); const port = server.address().port; const own = `127.0.0.1:${port}`; const cases = [ @@ -1102,12 +1104,14 @@ test("every refusal code the reader can raise has an HTTP status", () => { const src = readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => readFileSync(join(dir, f), "utf8")).join("\n"); const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1])); codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason + codes.add(NOT_LISTED); // raised by value (row 220 M7), so the literal scan misses it + codes.add("conversation-business-unreadable"); // raised by the board's creative predicate in serve.mjs (row 220 M7) assert.ok(codes.size >= 15, [...codes].join(" ")); assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []); assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries"); // The statuses themselves, so a changed value fails here even where no route test reaches it. assert.deepEqual(REFUSAL_STATUS, { - "unknown-conversation": 404, "unknown-branch": 404, unavailable: 404, + "unknown-conversation": 404, "unknown-branch": 404, unavailable: 404, "conversation-not-listed": 404, "conversation-business-unreadable": 503, "cursor-unknown": 409, "cursor-expired": 409, "cursor-foreign": 409, "source-replaced": 409, "incomplete-header": 409, "unsafe-path": 403, "foreign-project": 403, unreadable: 403, "unknown-actor": 403, "unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unsupported-purpose": 422, @@ -1117,7 +1121,7 @@ test("every refusal code the reader can raise has an HTTP status", () => { test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => { const f = conversationFixture(); const before = treePrint(f.sessionsDir); - const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "" }); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, businessDir: f.businessDir, isAlive: () => true, page: "" }); const base = `http://127.0.0.1:${server.address().port}`; const get = async (path) => { const res = await fetch(base + path); diff --git a/packages/conversation/src/reader.mjs b/packages/conversation/src/reader.mjs index ed266d2d2..7d60f6fad 100644 --- a/packages/conversation/src/reader.mjs +++ b/packages/conversation/src/reader.mjs @@ -22,6 +22,8 @@ import { samePath } from "../../seat/src/seat.mjs"; export const ACTOR = "local-operator"; export const HISTORY = "pi"; export const UNSUPPORTED_HARNESS = "unsupported-harness"; +// Case 2 design 5.1 M7: a creative root's conversations are not listed and do not open. +export const NOT_LISTED = "conversation-not-listed"; const NO_STREAM = "no-stream"; const HEAD_BYTES = 1024 * 1024; const TAIL_BYTES = 2 * 1024 * 1024; @@ -201,8 +203,20 @@ function readSnapshot(root, name, pinnedLength = null) { } } -export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * 1000, maxCursors = 1000, cacheSize = 2 } = {}) { +// creativeCheck() is the caller's per-call answer: it returns a predicate that is +// true when a root's seat is a creative instance on a business with a library +// project (design 5.1 M7). The reader calls it once per public call (catalogue, +// open, next), so one call reads the business sets once for every root. It may +// throw (an unreadable business file), which refuses the whole call. The reader +// only hides; it reads no business file itself. A truthy answer hides, so a +// doubtful one fails closed. The answer must depend on root.seat and root.project +// alone, because a cursor is checked against a root object from an earlier listing. +export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * 1000, maxCursors = 1000, cacheSize = 2, creativeCheck = () => () => false } = {}) { const listRoots = typeof roots === "function" ? roots : () => roots ?? []; + const hiddenCheck = () => { + const answer = creativeCheck(); + return (root) => Boolean(answer(root)); + }; const cursors = new Map(); const parsedCache = new Map(); const partsCache = new Map(); @@ -241,26 +255,34 @@ export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * return null; } + // A refusal from the hidden check (an unreadable business file) refuses the + // whole listing; it never lists some roots and drops the rest silently. function catalogue() { - const conversations = [], refusedRoots = []; - for (const root of listRoots()) { - if (root.unsupportedReason) { - conversations.push({ ...baseRow(root, unsupportedId(root)), engineStartedAt: root.engineStartedAt, availability: "unsupported" }); - continue; + try { + const hidden = hiddenCheck(); + const conversations = [], refusedRoots = []; + for (const root of listRoots()) { + if (hidden(root)) continue; + if (root.unsupportedReason) { + conversations.push({ ...baseRow(root, unsupportedId(root)), engineStartedAt: root.engineStartedAt, availability: "unsupported" }); + continue; + } + let listing; + try { + listing = listSessionFiles(root); + } catch (err) { + if (!(err instanceof Refusal)) throw err; + refusedRoots.push({ seat: root.seat, project: root.project, refusal: err.code }); + continue; + } + for (const name of listing.files) conversations.push(summarize(root, name)); + for (const r of listing.refused) conversations.push({ ...baseRow(root, conversationId(root, r.name)), availability: "denied", refusal: r.code }); } - let listing; - try { - listing = listSessionFiles(root); - } catch (err) { - if (!(err instanceof Refusal)) throw err; - refusedRoots.push({ seat: root.seat, project: root.project, refusal: err.code }); - continue; - } - for (const name of listing.files) conversations.push(summarize(root, name)); - for (const r of listing.refused) conversations.push({ ...baseRow(root, conversationId(root, r.name)), availability: "denied", refusal: r.code }); + conversations.sort((a, b) => (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? "") || a.conversation.localeCompare(b.conversation)); + return { ok: true, conversations, refusedRoots, generatedAt: new Date(now()).toISOString() }; + } catch (err) { + return refuse(err); } - conversations.sort((a, b) => (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? "") || a.conversation.localeCompare(b.conversation)); - return { ok: true, conversations, refusedRoots, generatedAt: new Date(now()).toISOString() }; } // Parsed snapshot plus one branch's entries, cached by content. A null @@ -347,8 +369,10 @@ export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * function open({ conversation, branch = null, actor = ACTOR, purpose = "history" } = {}) { try { checkCaller(actor, purpose); + const hidden = hiddenCheck(); const found = resolveConversation(conversation); if (!found) throw new Refusal("unknown-conversation", "no such conversation in the approved roots", { reconcile: true }); + if (hidden(found.root)) throw new Refusal(NOT_LISTED, "this conversation is not listed"); if (found.root.unsupportedReason) throw new Refusal(found.root.unsupportedReason, "this harness has no history reader yet"); const snap = snapshot(found.root, found.name); const built = build(snap, found.root, branch ?? null, conversation); @@ -362,6 +386,7 @@ export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * function next({ cursor, conversation, branch, actor = ACTOR, purpose = "history" } = {}) { try { + const hidden = hiddenCheck(); const held = typeof cursor === "string" ? cursors.get(cursor) : undefined; if (!held) throw new Refusal("cursor-unknown", "the cursor is unknown", { reconcile: true }); const { record, state } = held; @@ -372,6 +397,8 @@ export function createReader({ roots, now = () => Date.now(), ttlMs = 10 * 60 * if (actor !== record.actor || purpose !== record.purpose || conversation !== record.conversation || branch !== record.branch) { throw new Refusal("cursor-foreign", "the cursor belongs to another view", { reconcile: true }); } + // A cursor issued before the root became creative stops here (M7). + if (hidden(state.root)) throw new Refusal(NOT_LISTED, "this conversation is not listed"); // The source must still be the snapshot's file with the same prefix. const pinned = snapshot(state.root, state.name, state.length); if (pinned.shorter) throw new Refusal("source-replaced", "the session file is shorter than the snapshot", { reconcile: true }); diff --git a/packages/conversation/tests/reader.test.mjs b/packages/conversation/tests/reader.test.mjs index 198ca7e7c..cbd0497ad 100644 --- a/packages/conversation/tests/reader.test.mjs +++ b/packages/conversation/tests/reader.test.mjs @@ -692,6 +692,76 @@ test("F15: a Claude seat is an unsupported-harness placeholder whose directory i } }); +// M7 (Case 2 design 5.1): a creative seat's conversations are left out of the catalogue +// and refuse to open by id. The fixture text stands in for creative prose and must not +// appear in any output. +test("M7: a creative seat's session is not listed and won't open by id; an assistant seat still lists", () => { + const creative = project("bean"); + const assistant = project("sage"); + writeSession(creative.dir, "s.jsonl", header(creative.proj), [user("c1", null, "CREATIVE-MARKER", 1)]); + writeSession(assistant.dir, "s.jsonl", header(assistant.proj), [user("a1", null, "assistant text", 1)]); + const roots = rootsFromSpecs([creative.spec, assistant.spec]); + const reader = createReader({ roots, creativeCheck: () => (root) => root.seat === "bean" }); + const cat = noWrites(tmp, () => reader.catalogue()); + assert.deepEqual(cat.conversations.map((c) => c.seat), ["sage"]); + const hiddenOpen = noWrites(tmp, () => reader.open({ conversation: conversationId(roots.find((r) => r.seat === "bean"), "s.jsonl") })); + assert.deepEqual([hiddenOpen.ok, hiddenOpen.refusal.code, hiddenOpen.refusal.reconcile], [false, "conversation-not-listed", false]); + const shown = noWrites(tmp, () => reader.open({ conversation: conversationId(roots.find((r) => r.seat === "sage"), "s.jsonl") })); + assert.deepEqual([shown.ok, texts(shown.page)], [true, ["assistant text"]]); + assert.equal(JSON.stringify([cat, hiddenOpen]).includes("CREATIVE-MARKER"), false); +}); + +test("M7: one catalogue calls the creative check once, however many roots it lists", () => { + const creative = project("bean"); + const assistant = project("sage"); + writeSession(creative.dir, "s.jsonl", header(creative.proj), [user("c1", null, "one", 1)]); + writeSession(assistant.dir, "s.jsonl", header(assistant.proj), [user("a1", null, "two", 1)]); + const roots = rootsFromSpecs([creative.spec, assistant.spec]); + let calls = 0; + const reader = createReader({ roots, creativeCheck: () => { calls += 1; return (root) => root.seat === "bean"; } }); + const cat = noWrites(tmp, () => reader.catalogue()); + assert.deepEqual(cat.conversations.map((c) => c.seat), ["sage"]); + assert.equal(calls, 1); + const shown = noWrites(tmp, () => reader.open({ conversation: conversationId(roots.find((r) => r.seat === "sage"), "s.jsonl") })); + assert.equal(shown.ok, true); + assert.equal(calls, 2, "open is one more public call, so one more check"); +}); + +test("M7: a creative session already open refuses its next page once the root is hidden", () => { + const fx = project("bean"); + writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("c1", null, "CREATIVE-MARKER", 1), assistant("c2", "c1", "reply", 2)]); + let hide = false; + const roots = rootsFromSpecs([fx.spec]); + const reader = createReader({ roots, creativeCheck: () => () => hide }); + const id = conversationId(roots[0], "s.jsonl"); + const first = noWrites(fx.base, () => reader.open({ conversation: id })); + assert.equal(first.ok, true); + hide = true; + const r = noWrites(fx.base, () => reader.next({ cursor: first.follow.id, conversation: id, branch: first.page.branch })); + assert.deepEqual([r.ok, r.refusal.code], [false, "conversation-not-listed"]); +}); + +test("M7: a creative root's refused files and its unsupported placeholder are hidden as well", () => { + const fx = project("bean"); + writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("c1", null, "CREATIVE-MARKER", 1)]); + symlinkSync(join(fx.dir, "s.jsonl"), join(fx.dir, "link.jsonl")); + const chetDir = join(fx.proj, ".pi", "state", "chet", "sessions"); + const regs = [{ version: 1, seat: "chet", project: "proj", layout: "repo", harness: "claude-code", sessionsDir: chetDir, startedAt: time(-1) }]; + const roots = rootsFromSpecs([fx.spec], regs); + const plain = noWrites(fx.base, () => createReader({ roots }).catalogue()); + const placeholder = plain.conversations.find((c) => c.availability === "unsupported"); + assert.ok(placeholder, "without M7 the unsupported placeholder is listed"); + const reader = createReader({ roots, creativeCheck: () => (root) => root.seat === "bean" || root.seat === "chet" }); + const cat = noWrites(fx.base, () => reader.catalogue()); + assert.deepEqual(cat.conversations, []); + const bean = roots.find((r) => r.seat === "bean"); + for (const conversation of [placeholder.conversation, conversationId(bean, "link.jsonl"), conversationId(bean, "s.jsonl")]) { + const r = noWrites(fx.base, () => reader.open({ conversation })); + assert.deepEqual([r.ok, r.refusal.code], [false, "conversation-not-listed"]); + } + assert.equal(JSON.stringify(cat).includes("CREATIVE-MARKER"), false); +}); + test("unknown conversations, empty files and non-Pi files refuse", () => { const fx = project(); writeFileSync(join(fx.dir, "empty.jsonl"), ""); diff --git a/packages/discord/src/cli.mjs b/packages/discord/src/cli.mjs index ea0398aff..61dc1e0af 100755 --- a/packages/discord/src/cli.mjs +++ b/packages/discord/src/cli.mjs @@ -47,11 +47,12 @@ // not retry); 4 usage. import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { createHash } from "node:crypto"; import { DiscordError } from "./errors.mjs"; import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs"; import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs"; +import { loadSeatSets } from "../../business/src/library-seats.mjs"; import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs"; import { createRest } from "./rest.mjs"; import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs"; @@ -104,12 +105,33 @@ function warn(msg) { process.stderr.write(`discord: ${msg}\n`); } +// Case 2 M8 (design 5.1, row 220): a seat that works in a library business runs +// only on the mosaic-bus engine, with no tools key. The business files come from +// the config directory --config names. An unreadable file refuses; it never reads +// as "no library seats". +function checkLibraryEngine(configDirectory, binding) { + let sets; + try { + sets = loadSeatSets({ dir: configDirectory }); + } catch (err) { + throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2); + } + if (!sets.librarySeats.has(binding.seat)) return; + if (binding.engine.provider !== "mosaic-bus") { + throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; its engine must be mosaic-bus, not ${binding.engine.provider}`, 2); + } + if (binding.tools !== null) { + throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; a mosaic-bus binding there may not declare the tools key`, 2); + } +} + // Everything that can be checked without the network, shared by check and run. function prepare(opts) { const dataRoot = loadDataRoot(opts.config); const bindingFile = bindingPath(dataRoot, opts.binding); const binding = loadBinding(bindingFile); if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`); + checkLibraryEngine(dirname(opts.config), binding); const contextFiles = resolveContextFiles(binding, opts.repo); const toolRoots = resolveToolRoots(binding, { dataRoot }); const pi = join(opts.repo, "node_modules", ".bin", "pi"); diff --git a/packages/discord/tests/helpers.mjs b/packages/discord/tests/helpers.mjs index bd1e233c5..b7dafeb38 100644 --- a/packages/discord/tests/helpers.mjs +++ b/packages/discord/tests/helpers.mjs @@ -77,6 +77,9 @@ export function makeDeployment(root, overrides = {}, { tokenMode = 0o600, tokenC writeFileSync(bindingFile, JSON.stringify(raw, null, 2), { mode: 0o600 }); const config = join(root, "config.json"); writeFileSync(config, JSON.stringify({ dataRoot })); + // Row 220 M8: check reads the businesses directory beside --config. An empty + // one means no library seats; a missing one refuses. + mkdirSync(join(root, "businesses"), { recursive: true, mode: 0o700 }); return { dataRoot, config, bindingFile, tokenFile, raw, journalDir: join(dataRoot, "discord", raw.name) }; } diff --git a/packages/discord/tests/library-seat.test.mjs b/packages/discord/tests/library-seat.test.mjs new file mode 100644 index 000000000..1fb83f43f --- /dev/null +++ b/packages/discord/tests/library-seat.test.mjs @@ -0,0 +1,78 @@ +// Case 2 M8 (design 5.1, row 220): a binding for a seat in a library business +// runs only on the mosaic-bus engine with no tools key. The business files sit +// in the directory beside --config. An unreadable file refuses with +// discord-business-unreadable. Checks run with --offline: no network. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { makeRoot, makeRepo, makeDeployment } from "./helpers.mjs"; +import { businessDoc, writeJson } from "../../business/tests/helpers.mjs"; +import { mappingEntry } from "../../library/tests/helpers.mjs"; + +const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url)); +const MOSAIC_BUS = { provider: "mosaic-bus", model: "glm-5.3", thinking: "high" }; + +// overrides may be a function of the root, for fixtures that need a path under it. +function setup(overrides = {}) { + const root = makeRoot(); + const repo = makeRepo(root); + const extra = typeof overrides === "function" ? overrides(root) : overrides; + const d = makeDeployment(root, extra, { tokenContent: "MTAw.abcdefghijklmnopqrstuvwxyz0123456789" }); + return { root, repo, d }; +} + +// Writes businesses/acme.json under the deployment's config directory. A library +// business puts its roles (the seat "sage" here) in the library sets. +function business(s, { library, role = "sage", definition = "companion" }) { + const doc = businessDoc(s.root, "acme"); + if (library) doc.projects = { books: mappingEntry(), ...doc.projects }; + doc.roles[role] = { definition }; + writeJson(join(s.root, "businesses", "acme.json"), doc); +} + +function check(s) { + return spawnSync(process.execPath, [CLI, "check", s.d.raw.name, "--config", s.d.config, "--repo", s.repo, "--offline"], { encoding: "utf8", timeout: 10000 }); +} + +test("M8: a library seat's binding on a non-mosaic-bus engine is refused before any network", () => { + const s = setup(); + business(s, { library: true }); + const r = check(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-engine-not-allowed: .*its engine must be mosaic-bus, not zai/); +}); + +test("M8: a library seat's mosaic-bus binding that declares tools is refused, and the message names tools", () => { + const s = setup((root) => ({ engine: MOSAIC_BUS, tools: { roots: [{ name: "docs", path: join(root, "docs") }] } })); + business(s, { library: true }); + const r = check(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-engine-not-allowed: .*tools key/); +}); + +test("M8: a library seat's mosaic-bus binding with no tools passes the engine rule", () => { + const s = setup({ engine: MOSAIC_BUS }); + business(s, { library: true }); + const r = check(s); + assert.doesNotMatch(r.stderr, /discord-engine-not-allowed|discord-business-unreadable/); +}); + +test("M8: an unreadable business file refuses check with discord-business-unreadable", () => { + const s = setup(); + writeFileSync(join(s.root, "businesses", "broken.json"), "{"); + chmodSync(join(s.root, "businesses", "broken.json"), 0o600); + const r = check(s); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /discord-business-unreadable/); +}); + +test("M8: a binding on a seat that is in no library business checks clean, whatever its engine", () => { + const s = setup(); + business(s, { library: false }); + const r = check(s); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /offline check passed/); +}); diff --git a/packages/webui/tests/discord.test.mjs b/packages/webui/tests/discord.test.mjs index ef9851ed2..069f870c4 100644 --- a/packages/webui/tests/discord.test.mjs +++ b/packages/webui/tests/discord.test.mjs @@ -23,7 +23,8 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply // #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing. const seatsDir = join(root, 'seats'); writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' }); - board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } }); + mkdirSync(join(root, 'businesses')); + board = await startBoard({ port: 0, specs: [], seatsDir, businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } }); web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); const base = `http://127.0.0.1:${web.address().port}`; const payload = await (await fetch(base + '/api/board')).json(); diff --git a/packages/webui/tests/fixture.mjs b/packages/webui/tests/fixture.mjs index 1a3718efd..6dc1d9528 100644 --- a/packages/webui/tests/fixture.mjs +++ b/packages/webui/tests/fixture.mjs @@ -29,7 +29,8 @@ export async function fixture() { updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' }); } } - const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true, + mkdirSync(join(root, 'businesses')); + const board = await startBoard({ port: 0, specs, seatsDir, businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true, exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal ' : '' }; }, }); const boardURL = `http://127.0.0.1:${board.address().port}`; diff --git a/packages/webui/tests/history-fixture.mjs b/packages/webui/tests/history-fixture.mjs index 2120c3e6d..07852c0ad 100644 --- a/packages/webui/tests/history-fixture.mjs +++ b/packages/webui/tests/history-fixture.mjs @@ -36,7 +36,8 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {} mkdirSync(sessionsDir, { recursive: true }); const seatsDir = join(root, 'seats'), captures = []; writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) })); - const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, + mkdirSync(join(root, 'businesses')); + const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } }); const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, diff --git a/packages/webui/tests/relaunch.test.mjs b/packages/webui/tests/relaunch.test.mjs index 08275514f..31bc2316e 100644 --- a/packages/webui/tests/relaunch.test.mjs +++ b/packages/webui/tests/relaunch.test.mjs @@ -23,7 +23,8 @@ test('both presentations replace old activity with relaunch notice, label retain writeFileSync(file, history); const seatsDir = join(root, 'seats'); writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' }); - board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } }); + mkdirSync(join(root, 'businesses')); + board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } }); const boardURL = `http://127.0.0.1:${board.address().port}`; web = await startServer({ port: 0, board: boardURL }); const webURL = `http://127.0.0.1:${web.address().port}`; diff --git a/packages/webui/tests/return-flow.test.mjs b/packages/webui/tests/return-flow.test.mjs index d8dec0911..99ec95d77 100644 --- a/packages/webui/tests/return-flow.test.mjs +++ b/packages/webui/tests/return-flow.test.mjs @@ -29,7 +29,8 @@ test('reported return flow and relative Age: reply sent from the inspector, then writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER')); const seatsDir = join(root, 'seats'), captures = []; writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) })); - board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, + mkdirSync(join(root, 'businesses')); + board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], businessDir: join(root, 'businesses'), boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } }); web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); b = await browser(); await b.viewport(1440, 1000);