Compare commits

...
19 changed files with 1079 additions and 42 deletions

No files matched your search

+100
View File
@@ -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.<instance>.definition` in `CREATIVE_DEFINITIONS` (`packages/business/src/vocabulary.mjs:176`). A library project is `projects.<id>.kind === "library"` (`packages/business/src/business.mjs:243`).
- Creative thread sessions are stored at `<dataRoot>/threads/<business>/<thread>/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/<id>.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.
@@ -0,0 +1,385 @@
# 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` (renamed `configDirectory` in round 2b below). 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 `<project>/.pi/state/<seat>/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).
## Round 2b: Filbert's early look and sage-2719 (2026-10-22T14:46Z)
Added after the text above; nothing above is edited. Where earlier text says `businessDir`, the parameter is
now `configDirectory` (below). Second commit on `researcher/row220-r2`, on top of ea6dc9eff. Not pushed, not
run; `node --check` has not been run on any of these files (no node before the CLEAR).
Rulings applied (sage-2719): Q1 keep the index export. C1, C2 and the fixture scope are changes.
### Fixture scope: the parameter is a config directory
`createServer` took `businessDir`, but `creativeCheck(dir)` reads `<dir>/businesses`, so every fixture that
passed `join(root, 'businesses')` made the reader look in `<root>/businesses/businesses`. That read nothing
(ENOENT, empty sets): isolation from the real config held by accident. Now:
- `createServer({ configDirectory = configDir() })` in `serve.mjs`. The parameter is not named `configDir`
because the import of that name is the default's source.
- Every fixture passes the config directory it owns: `configDirectory: root` in the five webui files,
`f.root` in `control-board/tests/discord.test.mjs`, and `f.configDirectory` in `serve.test.mjs`.
`conversationFixture()` and `replyFixture()` both return `configDirectory: root`. The seven reply starts
that passed `f.businessDir` (undefined, falling back to the real `configDir()`) now pass a real value.
- The dead `mkdirSync(join(root, 'businesses'))` lines in the five webui fixtures and in `serve.test.mjs`
are removed. So is the one in `discord/tests/helpers.mjs` `makeDeployment`, whose comment said "a missing
one refuses"; that was false (lstat ENOENT gives empty sets). The comment now says nothing there means no
library seat. Tests that need a `businesses/` directory create it: `writeJson` does, and the two
unreadable-file tests in `library-seat.test.mjs` call `mkdirSync` themselves.
- Proof that the fixture's own business file is the one read: the new default-reader test (C2) hides `bean`
and shows `pm`, and nothing else in that fixture says which is which.
### C1: M8 on `run`
`library-seat.test.mjs` gains three `run` tests: a raw engine, a mosaic-bus binding with `tools`, and an
unreadable business file. Each asserts exit 2 and the code in stderr, and that `s.d.journalDir` does not
exist (`ensureJournal` comes after `prepare()` in `run`, so nothing was started). `run()` calls
`initializeControl(dataRoot)` first, because `run` stops at `assertProcessing` before `prepare()`, as the
STOP test in `binding.test.mjs:203` does. There is no `run` test for a clean seat: that would go on to the
network.
### C2: the default reader
`creative-hidden.test.mjs`, "the board's default reader hides a creative seat's session from the listing and
the detail route". It calls `startServer` with no `conversationReader`, `configDirectory: f.dir`, a `bean`
session and a `pm` session on disk, and drives HTTP: the listing holds `pm` only; bean's detail is 404
`conversation-not-listed`; pm's detail is 200; after a broken business file lands the listing is 503
`conversation-business-unreadable`.
### Mutants to throw (CLEAR only; none thrown; the kill claims are unverified)
| Id | Mutation | Meant to be killed by |
|---|---|---|
| M7-w | delete `creativeCheck: creativeCheck(configDirectory),` in `createServer` (`serve.mjs`) | the default-reader test, first assertion (the listing shows `bean`) |
| M7-c | `hiddenCheck()` in `reader.mjs` open() returns `() => false` | reader.test "hides the creative seat" and the `creative-hidden` 404 asserts |
| M7-o | `creativeCheck` catch returns an empty set instead of throwing | the 503 asserts in `creative-hidden` |
| M8-p | delete `checkLibraryEngine(...)` in `prepare()` | the three `check` refusals and the three `run` refusals; one mutant covers both verbs because they share `prepare()` |
| M8-e | `loadSeatSets` returns empty sets on any lstat error | `library-seats` ENOENT-vs-file tests |
### `refusalResponse` and `catalogue()`'s refusal (past "predicate wiring and REFUSAL_STATUS only")
Why it is needed: the reader's `catalogue()` now returns a refusal when the business check throws (M7: no
partial list), and `conversationResponse` had no way to turn a catalogue refusal into a status. `open` and
`next` already built `{ status: REFUSAL_STATUS[code] ?? 422, body: { error, refusal: { code, reconcile } } }`
inline; that expression moved, character for character, into `refusalResponse(out)` so the listing could
use it too.
No existing route's status or body changes:
- `open` and `next` refusals: the same expression, now behind a function. `git diff aca16404c..HEAD --
packages/control-board/src/serve.mjs` shows the line as a move.
- `/api/conversations` success: `{ status: 200, body: listing }` where `listing` is what the route returned
before. `catalogue()` returned `ok: true` on every path before this row, so the new branch is reached only
by the business refusal.
- The "no parameters are accepted" 400 is untouched.
Phase B runs `serve.test.mjs`, `discord.test.mjs` and the conversation route tests whole, so Filbert can
check this statement against a green run.
### Smaller changes from the early look
- Spy test: one `next()` call adds a third `creativeCheck` count (`reader.test.mjs`). The catalogue assert
also checks `refusedRoots` is empty, in the same test and in the thread-store test in `creative-hidden`.
- `library-seats.test.mjs` mode-000 test pins `BusinessError` and its message (`must be a regular,
non-symbolic-link file`, from `readJsonFile`'s open failure).
- "passes the engine rule" now asserts exit 0 and `offline check passed`. The expectation is unverified: if
a mosaic-bus binding fails some other offline check, that is a first-run red to place (mos-1272).
### Carried, not changed here
- `conversation/src/controller.mjs:176` builds a reader with no `creativeCheck`. No `src` file imports it
today; a live wiring must pass the check. Raised for Sage.
- `discord run` reads the business file once at start. `engine` and `tools` are FIXED_KEYS, so only a
business-file change while a connector runs goes unseen until the next start.
- `creative-hidden` and `library-seats` use the repo `roles/` as the default `rolesDir`.
### Gate additions
Phase B list is unchanged: the same suites run whole, plus the files above are inside them. The drift check
against `aca16404c..refactor`, including files these tests copy or read, repeats after the rebase onto 253.
## Round 2c: refusalResponse statement corrected from the diff, controller.mjs follow-up (2026-10-22T14:55Z, sage-2729)
Appended after Round 2b; nothing above is edited. This replaces Round 2b's refusalResponse statement, which I
wrote from memory of the code. Source: `git diff aca16404c HEAD -- packages/control-board/src/serve.mjs` and
`packages/conversation/src/reader.mjs`, read in full. No test has run.
### What the diff shows in serve.mjs
- `open` and `next` refusal: the object expression `{ status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: {
error: out.refusal.message, refusal: { code, reconcile } } }` moved unchanged into `refusalResponse(out)`.
The two call sites reach it by `return refusalResponse(out)`. Status and body are identical for every code
that reached it before.
- `REFUSAL_STATUS` gains two keys, `conversation-not-listed` (404) and `conversation-business-unreadable`
(503). Before, those codes did not exist, so no earlier response used them or changes. Every other key is
untouched.
- `/api/conversations` with a query string: the 400 `no parameters are accepted` line is untouched.
- `/api/conversations` success: before, `{ status: 200, body: reader.catalogue() }`; now the same object
when `listing.ok` (it is the same `listing`), so the 200 body is unchanged.
- The one new response shape: `/api/conversations` can return a refusal (503 or the refusal's status) when
the business check throws. That is the M7 behaviour, not a change to an existing route's response.
### What the diff shows in reader.mjs, past my Round 2b statement
`catalogue()` is now wrapped in `try { ... } catch (err) { return refuse(err); }`. `refuse` returns a refusal
object for a `Refusal` and rethrows anything else (`reader.mjs:352-355`). So a non-Refusal error still
propagates as before. A `Refusal` escaping the loop body would now come back as `{ ok: false }` where it
used to throw. The loop already catches `Refusal` from `listSessionFiles` and from `summarize`, so the only
`Refusal` that newly reaches the wrapper is the hidden check's. I have not confirmed that `listRoots()`
(`rootsFromSpecs`, `loadRegistrations`) never throws a `Refusal`; if it does, that call now returns a
refusal. Filbert's round check should read `rootsFromSpecs` for `Refusal` throws.
Round 2b said the old `catalogue()` "returned ok: true on every path". True for returns; it could throw.
### Named follow-up for DEFERRED (Sage lands it with 220; it does not block 220)
`packages/conversation/src/controller.mjs:176` builds a reader with no `creativeCheck`. No `src` file imports
`controller.mjs` today, so no live path leaks. Whoever first wires `controller.mjs` live must pass
`creativeCheck` (`creativeCheck(configDirectory)` from `control-board/src/serve.mjs`, or the reader's own
equivalent) and add a test that a creative seat's sessions stay hidden through that path.
## Round 2d: can listRoots() throw a Refusal into catalogue()? No (2026-10-22T14:53Z, sage-2730)
Appended after Round 2c; closes the question Round 2c left open. Static read on 9a9f6dd54; nothing run.
Filbert reached the same answer independently (filbert-220-r2c-static).
`catalogue()` calls `listRoots()` inside the new `try`. In production that is `serve.mjs:261`,
`roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations)`. The only `Refusal` class
the reader catches is the one in `conversation/src/safe-fs.mjs:21`.
- `rootsFromSpecs` (`conversation/src/reader.mjs:38-71`) calls `resolve`, `join`, `basename`, `Array.find`,
`toTime` and `samePath`, and constructs no `Refusal`.
- `toTime` (`conversation/src/pi.mjs:21-26`) returns a value or null and has no throw.
- `samePath` (`seat/src/seat.mjs:261-269`) wraps `realpathSync` in try/catch and returns false.
- `loadRegistrations` (`control-board/src/scan.mjs:272-292`) catches `SeatError` and rethrows anything
else. It calls `readRegistration` (`seat/src/seat.mjs:200-216`), which throws `SeatError` only.
- `grep -n "Refusal\|refuse(" packages/control-board/src/scan.mjs packages/seat/src/seat.mjs` finds
nothing, and neither file imports `safe-fs.mjs` (checked with `grep -n "^import"`). A `Refusal` cannot
originate in either.
- Inside the loop, `listSessionFiles` is caught at `reader.mjs` (the `catch (err)` that rethrows
non-Refusals) and `summarize` turns an `openSessionFile` Refusal into a `denied` row.
So the only `Refusal` that reaches the new catch is the hidden check's `conversation-business-unreadable`.
Any other error still throws through `refuse()` (`reader.mjs:352-355`) exactly as before.
Therefore `/api/conversations` has no status change for a path that existed before this row: the old
throw paths are unreachable by a `Refusal`. The one new response is the business refusal, already pinned by
the creative-hidden 503 assertion and the reader catalogue-refusal test. No extra test is added.
The exception is an injected `roots` function that throws a `Refusal` itself; only a test could pass one,
and none does.
Time correction (2026-10-22T14:51:01Z): the heading times of Round 2c (14:55Z) and Round 2d (14:53Z) were written before I read the clock. The commits were made at 14:49:40Z (9a9f6dd54) and 14:50:52Z (176ac262e). The heading of Round 2b (14:46Z) is also an estimate; its commit e4a204836 was made before 14:47:53Z.
+1
View File
@@ -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";
+51
View File
@@ -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 };
}
@@ -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), (err) => err instanceof BusinessError && /must be a regular, non-symbolic-link file/.test(err.message));
});
+36 -4
View File
@@ -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,43 @@ 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 }) {
// specs: agent specs to scan on each request. boardDir: where scan writes. configDirectory: the config
// directory whose businesses/ the default conversation reader reads for M7 (the instance's, by default).
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null, configDirectory = 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(configDirectory),
});
return createHttpServer((req, res) => {
const refused = foreignRequest(req);
if (refused) {
@@ -0,0 +1,130 @@
// 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, startServer, 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 <data>/threads/<business>/<thread>.
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) });
const cat = reader.catalogue();
assert.deepEqual([cat.conversations, cat.refusedRoots], [[], []], "the root is hidden, not refused");
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);
});
// C2: every test above builds its own reader. This one starts the board with no
// injected reader, so the check reaches the reader only through createServer's
// wiring (serve.mjs: `creativeCheck: creativeCheck(configDirectory)`). Deleting that
// line shows bean's session in the listing and fails the first assertion below.
// It also shows the business file in the fixture's config directory is the one
// read: bean is hidden and pm is not, and no other file says so.
test("M7: the board's default reader hides a creative seat's session from the listing and the detail route", async (t) => {
const f = fixture(t);
const proj = join(f.root, "proj");
const specs = [];
for (const [n, seat] of ["bean", "pm"].entries()) {
const sessionsDir = join(proj, ".pi", "state", seat, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const header = { type: "session", version: 3, id: `c0ffee00-0000-4000-8000-00000000000${n + 1}`, timestamp: "2026-10-22T12:00:00.000Z", cwd: proj };
const message = { type: "message", id: "e0", parentId: null, timestamp: "2026-10-22T12:00:01.000Z", message: { role: "user", content: [{ type: "text", text: `${seat.toUpperCase()}-TEXT` }] } };
writeFileSync(join(sessionsDir, "s.jsonl"), [header, message].map((l) => JSON.stringify(l)).join("\n") + "\n");
specs.push({ agent: seat, project: "proj", sessionsDir, tmux: {} });
}
const roots = rootsFromSpecs(specs);
const idOf = (seat) => conversationId(roots.find((r) => r.seat === seat), "s.jsonl");
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(f.root, "board"), seatsDir: join(f.root, "seats"), configDirectory: f.dir, isAlive: () => true, page: "<html></html>" });
t.after(() => { server.closeAllConnections(); server.close(); });
const base = `http://127.0.0.1:${server.address().port}`;
const get = async (path) => {
const res = await fetch(base + path);
return { status: res.status, text: await res.text() };
};
const cat = await get("/api/conversations");
assert.equal(cat.status, 200, cat.text);
assert.deepEqual(JSON.parse(cat.text).conversations.map((c) => c.seat), ["pm"]);
assert.ok(!cat.text.includes("BEAN-TEXT"));
const hidden = await get(`/api/conversation?id=${encodeURIComponent(idOf("bean"))}`);
assert.equal(hidden.status, 404, hidden.text);
assert.equal(JSON.parse(hidden.text).refusal.code, "conversation-not-listed");
assert.ok(!hidden.text.includes("BEAN-TEXT"));
const shown = await get(`/api/conversation?id=${encodeURIComponent(idOf("pm"))}`);
assert.equal(shown.status, 200, shown.text);
// The file is read on every call: a broken business file now refuses the same route.
breakBusinessFile(f.dir);
const refused = await get("/api/conversations");
assert.equal(refused.status, 503, refused.text);
assert.equal(JSON.parse(refused.text).refusal.code, "conversation-business-unreadable");
});
@@ -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, configDirectory: f.root, 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);
+16 -13
View File
@@ -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");
@@ -725,7 +725,7 @@ function replyFixture({ tmux = { socket: null, session: "agent1" }, pid = proces
}
const capture = join(root, "capture.json");
process.env.FAKE_SEND_CAPTURE = capture;
return { root, sessionsDir, seatsDir, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture,
return { root, sessionsDir, seatsDir, configDirectory: root, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture,
specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
}
@@ -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 <host>: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: "<html></html>", 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, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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 <hos
test("POST /api/reply: a registration with a tmux socket adds -L <socket>", 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: "<html></html>", agentSend: f.agentSend });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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 <socket>", 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: "<html></html>", agentSend: f.agentSend });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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: "<html></html>", 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, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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: "<html></html>", 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, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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: "<html></html>", agentSend: f.agentSend });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", 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: "<html></html>", agentSend: f.agentSend });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", agentSend: f.agentSend });
const port = server.address().port;
delete process.env.FAKE_SEND_EXIT;
delete process.env.FAKE_SEND_STDERR;
@@ -1049,7 +1049,8 @@ 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 <root>/businesses, which does not exist here, never the real config.
return { root, proj, sessionsDir, seatsDir, configDirectory: root, 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 +1071,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: "<html></html>", conversationReader: spy });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>", conversationReader: spy });
const port = server.address().port;
const own = `127.0.0.1:${port}`;
const cases = [
@@ -1102,12 +1103,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 +1120,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: "<html></html>" });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, configDirectory: f.configDirectory, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
const get = async (path) => {
const res = await fetch(base + path);
+45 -18
View File
@@ -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 });
@@ -692,6 +692,79 @@ 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");
noWrites(tmp, () => reader.next({ cursor: shown.follow.id, conversation: conversationId(roots.find((r) => r.seat === "sage"), "s.jsonl"), branch: shown.page.branch }));
assert.equal(calls, 3, "next is one more public call, so one more check");
assert.deepEqual(cat.refusedRoots, [], "a hidden root is not a refused root");
});
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"), "");
+23 -1
View File
@@ -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");
+3
View File
@@ -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 and run read <root>/businesses beside --config. Nothing
// there means no library seat, so a deployment starts with none; the tests
// that need one write a business file into it.
return { dataRoot, config, bindingFile, tokenFile, raw, journalDir: join(dataRoot, "discord", raw.name) };
}
@@ -0,0 +1,118 @@
// 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. Both verbs refuse: check runs with --offline, and
// run refuses in prepare(), before the journal is made and before any network.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { makeRoot, makeRepo, makeDeployment } from "./helpers.mjs";
import { initializeControl } from "../../processing-control/src/state.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);
}
// run stops at the processing gate first, so the deployment's control state is initialised.
function run(s) {
initializeControl(s.d.dataRoot);
return spawnSync(process.execPath, [CLI, "run", s.d.raw.name, "--config", s.d.config, "--repo", s.repo], { encoding: "utf8", timeout: 10000 });
}
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/);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /offline check passed/);
});
test("M8: an unreadable business file refuses check with discord-business-unreadable", () => {
const s = setup();
mkdirSync(join(s.root, "businesses"));
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/);
});
test("M8: run refuses a library seat's raw engine with exit 2, before the journal is made and before any network", () => {
const s = setup();
business(s, { library: true });
const r = run(s);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /discord-engine-not-allowed: .*its engine must be mosaic-bus, not zai/);
assert.equal(existsSync(s.d.journalDir), false, "nothing was started");
});
test("M8: run refuses a library seat's mosaic-bus binding that declares tools", () => {
const s = setup((root) => ({ engine: MOSAIC_BUS, tools: { roots: [{ name: "docs", path: join(root, "docs") }] } }));
business(s, { library: true });
const r = run(s);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /discord-engine-not-allowed: .*tools key/);
assert.equal(existsSync(s.d.journalDir), false);
});
test("M8: an unreadable business file refuses run with discord-business-unreadable, before the journal is made", () => {
const s = setup();
mkdirSync(join(s.root, "businesses"));
writeFileSync(join(s.root, "businesses", "broken.json"), "{");
chmodSync(join(s.root, "businesses", "broken.json"), 0o600);
const r = run(s);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /discord-business-unreadable/);
assert.equal(existsSync(s.d.journalDir), false);
});
+1 -1
View File
@@ -23,7 +23,7 @@ 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'); } });
board = await startBoard({ port: 0, specs: [], seatsDir, configDirectory: root, 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();
+1 -1
View File
@@ -29,7 +29,7 @@ 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,
const board = await startBoard({ port: 0, specs, seatsDir, configDirectory: root, 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 <unsafe>' : '' }; },
});
const boardURL = `http://127.0.0.1:${board.address().port}`;
+1 -1
View File
@@ -36,7 +36,7 @@ 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,
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], configDirectory: root, 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,
+1 -1
View File
@@ -23,7 +23,7 @@ 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'); } });
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], configDirectory: root, 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}`;
+1 -1
View File
@@ -29,7 +29,7 @@ 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,
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], configDirectory: root, 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);