Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f380b69de3 | ||
|
|
176ac262ea | ||
|
|
9a9f6dd540 | ||
|
|
e4a2048360 | ||
|
|
ea6dc9eff5 |
No files matched your search
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"), "");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user