feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control board adds GET /api/conversations and /api/conversation behind the Host and Origin guard. Both are read-only, their queries are validated, and each refusal code maps to a status. Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code: R1 revise (branch ids moving on append, the assumed-link bridge merging branches, one unreadable seat directory turning the catalogue into a 500), then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve (07b10ad1), R2 approve (b9d92003). The package lands with the routes, because serve.mjs imports the reader at load. On an index export: the eight suites 24/90/43/17/14/15/63/18, conversation and control-board 153/153, webui 9/9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# CHAT-02 backend: review packet (#1507, row 5)
|
||||
|
||||
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.1 and D3.
|
||||
Base: `34777c56` on `refactor`. Nothing here is committed; the candidate is
|
||||
the working tree, pinned by the hashes below.
|
||||
|
||||
Reviewers (Sage's order):
|
||||
- Filbert: the code, all nine files.
|
||||
- Darkwing: the two board routes, `serve.mjs` and `serve.test.mjs`.
|
||||
|
||||
The Console (`packages/webui`) is the next step and is not in this packet.
|
||||
|
||||
## 1. Candidate hashes
|
||||
|
||||
```
|
||||
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
|
||||
6f2cab5a31a26f71cf1cc00d6aed2a683e011b6cc2b52ebd4bb50b404af96d5c packages/conversation/README.md
|
||||
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
|
||||
28e22501ab133af7dee1a38f0dc209d005ead4192b64dae6ef999ec4191db8e0 packages/conversation/src/pi.mjs
|
||||
1e1db046c65292dbcfa2d59b6f0d5009dc48062cbd6a7ec123a18b6707ec8142 packages/conversation/src/reader.mjs
|
||||
98835b171c1473de2ba242347b191773dfac1e50f383e43ec43b2ef4a5122a3a packages/conversation/src/safe-fs.mjs
|
||||
3f89f5e7a3cdd5bb31623c799105c538fd5b5eacae368af0170e43f1130267e6 packages/conversation/tests/reader.test.mjs
|
||||
afc95bdb850e4b533516984d2dd7f3ff852c90b46b384f42bc550adf9f2c540d packages/control-board/src/serve.mjs
|
||||
e60aa14b77350a2807e9e9b887acb9daec1c0468d94baaf9f77548dc54fcecbc packages/control-board/tests/serve.test.mjs
|
||||
```
|
||||
|
||||
The first seven files are new. `serve.mjs` and `serve.test.mjs` are diffs
|
||||
against `34777c56`: `git diff 34777c56 -- packages/control-board`.
|
||||
`scan.mjs` is unchanged. D3 allowed edits there, but none were needed.
|
||||
|
||||
## 2. What it does
|
||||
|
||||
`packages/conversation/README.md` is the reference: API, sources, safe open,
|
||||
parser rules, page limits, snapshot, epoch and cursor rules, follow, the
|
||||
refusal table and costs. In short:
|
||||
|
||||
- `rootsFromSpecs` turns the board's repository specs into roots, using
|
||||
registrations only as hints. `createReader` serves `catalogue`, `open` and
|
||||
`next`.
|
||||
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
|
||||
Console needs beyond CHAT-01 goes in `view`.
|
||||
- The board serves `GET /api/conversations` (no parameters) and
|
||||
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
|
||||
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
|
||||
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
|
||||
headers.
|
||||
- Status map:
|
||||
- 404 for an unknown conversation or branch;
|
||||
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
|
||||
- 403 for `unsafe-path`, `foreign-project` and `unreadable`;
|
||||
- 422 for the rest;
|
||||
- 400 for any query parameter other than `id`, `branch` or `cursor`, a
|
||||
repeated one, or a value that fails the id pattern;
|
||||
- 500 with a fixed message for an exception, with details only on stderr.
|
||||
|
||||
## 3. Choices and deviations to review
|
||||
|
||||
1. **Catalogue rows are a summary shape, not CHAT-01 `catalogueItem`.** The
|
||||
row is: conversation, seat, project, harness, history, title, readOnly,
|
||||
`controlMode: "unavailable"`, the three separate time fields,
|
||||
availability, `unsupportedReason` and refusal. `catalogueItem` carries
|
||||
binding and control fields that belong to CHAT-03. I did not want to fill
|
||||
them with placeholders that look authoritative.
|
||||
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
|
||||
have no `sessionFile` field. The brief §2.1 rule is applied to the
|
||||
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
|
||||
must all match. A registration never adds a root or names a file (F9).
|
||||
3. **Placeholder rows for non-Pi seats.** A seat on another harness has no Pi
|
||||
directory, so the board has no spec for it. A registration adds one
|
||||
`unsupported-harness` row when its `sessionsDir` is the standard
|
||||
directory under a project root that is already approved. Its directory is
|
||||
never read. Rocko (`claude-code`) is the live case (F15).
|
||||
4. **An epoch id is comparable only within one cursor chain.**
|
||||
`sourceEpoch = "e-" + sha256(dev:ino:digest)`, taken at open and carried
|
||||
forward while the prefix verifies. Two opens of an unchanged file give the
|
||||
same value. After growth, a new open gets a new value for the same epoch,
|
||||
while the old chain keeps its own. Only a new inode, a shorter file or a
|
||||
changed prefix is a new epoch, and that is refused.
|
||||
5. **Follow semantics.** On the last page, `follow` replaces `cursor`.
|
||||
- `next` with it re-verifies the pinned prefix and takes a fresh snapshot.
|
||||
- If the view's leaf is still on the new default branch, the ids of the
|
||||
parts already served must be unchanged. It then returns only the new
|
||||
parts.
|
||||
- If the conversation went on down another branch, it returns an empty
|
||||
page on the old branch and reports the new default in `view`.
|
||||
- Silently switching branches would break "nothing switches silently".
|
||||
6. **Missing parent next to malformed lines is bridged.** The history
|
||||
continues from the previous valid entry, with an "assumed link" notice.
|
||||
Pi itself would drop the history before that point. Without adjacent
|
||||
malformed lines, the history stops with a notice. A follow refuses if a
|
||||
line that was malformed now reads differently, which changes meaning
|
||||
(F1 follow test).
|
||||
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing or file
|
||||
open becomes a per-row 403, so one unreadable file cannot fail the whole
|
||||
catalogue.
|
||||
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
|
||||
18.6 MB.
|
||||
9. **Every page re-hashes the whole pinned prefix.** This makes detection
|
||||
simple and total, at a cost measured in §4.3.
|
||||
10. **Refusal code names** not already in CHAT-01 are CHAT-02 values, like
|
||||
`unsupported-harness` (D2): `unsafe-path`, `foreign-project`,
|
||||
`unreadable`, `not-a-pi-session`, `incomplete-header`, `too-large`,
|
||||
`unknown-branch`, `unknown-actor` and `unsupported-purpose`.
|
||||
|
||||
## 4. Evidence
|
||||
|
||||
All runs are on the candidate hashes above.
|
||||
|
||||
### 4.1 Suites and contract checks
|
||||
|
||||
- `node --test --test-concurrency=1 packages/conversation/tests/
|
||||
packages/control-board/tests/ packages/webui/tests/ packages/seat/tests/`:
|
||||
175 tests, 175 pass.
|
||||
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
|
||||
`chat-01c/check.mjs` all exit 0.
|
||||
|
||||
### 4.2 Fixture map (brief §2.1)
|
||||
|
||||
| # | Test |
|
||||
|---|---|
|
||||
| F1 | three tests in `reader.test.mjs`: malformed notice in place (with a hostile id), bridge and missing parent, and the follow that refuses when history changes meaning |
|
||||
| F2–F15 | one named test each in `reader.test.mjs` |
|
||||
| F16 | `serve.test.mjs` §12: foreign Host and cross-origin Origin on both routes give 403; a spy reader records zero calls, and no `access-control-*` header is sent |
|
||||
| F17 | every reader call in `reader.test.mjs` runs inside a fingerprint of the whole fixture tree: size, SHA-256, mtime (ns), (dev, ino), mode and every directory listing. The route test in `serve.test.mjs` does the same. Files no read may touch are mode 000. |
|
||||
|
||||
Additional coverage: mapping of every Pi entry type and role, pagination
|
||||
at 100 parts and at the byte cap, surrogate-safe fragments, unknown and
|
||||
empty and non-Pi files, `unreadable`, and CHAT-01 schema validation of
|
||||
every page and cursor the tests produce (Python `jsonschema`).
|
||||
|
||||
### 4.3 Real data (read-only, this checkout)
|
||||
|
||||
- Catalogue: 19 rows. There are 18 available Pi conversations across darkwing,
|
||||
dewey, filbert, researcher and sage, plus rocko as `unsupported-harness`.
|
||||
- Reading every page of every conversation gave 102 pages and 8693 entries,
|
||||
with no refusals. The slowest conversation took 662 ms; the largest file is
|
||||
18.6 MB.
|
||||
- 186 pages and cursors sampled from that run: 0 schema-invalid.
|
||||
- Over HTTP, through a temporary board on port 0 with a temporary `boardDir`
|
||||
(the live board was not touched): 19 rows and 102 pages in 1517 ms. Rocko
|
||||
returns 422 `unsupported-harness`.
|
||||
- Scripts and output: `evidence/backend/smoke.mjs`, `smoke-stats.txt`,
|
||||
`http-real.mjs`, `http-out.txt`.
|
||||
|
||||
### 4.4 Mutation testing
|
||||
|
||||
Mutations were run in scratch copies under `/tmp/dewey-chat02/`, never in
|
||||
the served tree. Scripts and full output: `evidence/backend/mutate.py`,
|
||||
`mutate-board.py`, `mutate-backend.txt` and `mutate-routes.txt`.
|
||||
|
||||
Reader: 27 of 28 caught.
|
||||
|
||||
| Mutant | Caught by |
|
||||
|---|---|
|
||||
| no O_NOFOLLOW and no lstat symlink check | F7, F8 |
|
||||
| directory components unchecked | F7 |
|
||||
| listing follows symlinks | F7, F8 |
|
||||
| no prefix digest check | F4 |
|
||||
| no dev/ino check | F3 |
|
||||
| no shorter check | **not caught, equivalent** (below) |
|
||||
| cwd always in project | F10 |
|
||||
| no character cap | F14, schema |
|
||||
| no page byte cap | F14 |
|
||||
| no block cap | F14, schema |
|
||||
| no foreign check | F6 |
|
||||
| no branch binding | F6 |
|
||||
| no expiry | F6 |
|
||||
| no parentSession notice | F11 |
|
||||
| branch parameter ignored | F12 |
|
||||
| registration harness ignored | F15 |
|
||||
| no placeholder roots | F15 |
|
||||
| malformed notices dropped | F1 |
|
||||
| no bridge | F1 |
|
||||
| `incomplete` never set | F2, F5 |
|
||||
| `next` re-reads fresh instead of pinned | F2, F5, F12, unknown/empty |
|
||||
| follow skips history check | F1 follow |
|
||||
| follow ignores branching | F12 |
|
||||
| catalogue writes a file | F17 in six fixtures |
|
||||
| wrong thinking visibility | mapping |
|
||||
| raw tool ids | mapping, schema |
|
||||
| id namespaces dropped | F1 |
|
||||
| surrogate pair split | fragments |
|
||||
|
||||
The equivalent mutant: removing the "file is shorter" check changes nothing
|
||||
observable. A shorter file cannot supply the pinned length, so the prefix
|
||||
digest differs and `source-replaced` still follows. The check stays because
|
||||
it gives the refusal without hashing a truncated read.
|
||||
|
||||
Board routes: 9 of 9 caught. The mutants were: routes before the guard, no
|
||||
`nosniff`, every refusal 422, no query validation, unknown parameters
|
||||
allowed, `reconcile` dropped, cursor ignored, registrations ignored, and a
|
||||
CORS header sent.
|
||||
|
||||
## 5. Known limits
|
||||
|
||||
- **No `openat` in Node.** A directory component swapped between the checks
|
||||
and the open is caught by the re-check after the open, not prevented.
|
||||
Nothing is read from a descriptor whose (dev, ino) does not match.
|
||||
- **The `fstat` mismatch branch of F8** is covered by design, not by a
|
||||
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
|
||||
plain-file swap between `lstat` and `open` needs a race the suite cannot
|
||||
schedule.
|
||||
- **An in-place rewrite with the same inode** is detected by the digest of
|
||||
the pinned prefix, as the brief requires. Growth after the pin is not a
|
||||
change.
|
||||
- **Cost.** Each page reads and hashes the whole pinned prefix; §4.3 has the
|
||||
cost on real files.
|
||||
- **One actor.** Cursors live in memory and are lost when the board restarts;
|
||||
the refusal is `cursor-unknown` with reconcile. `local-operator` is the
|
||||
only actor, which is not multi-actor safety (CHAT-04R).
|
||||
- **Claude history** waits for B1 and CHAT-03 (D2).
|
||||
|
||||
## 6. After review
|
||||
|
||||
- Pushing goes through Sage with Jason's word. The live board needs a restart
|
||||
after the commit to serve the routes.
|
||||
- The WebUI proxy allowlist for the two routes is Console work and is not in
|
||||
this packet.
|
||||
@@ -0,0 +1,340 @@
|
||||
# CHAT-02 backend: review packet, revision 2 (#1507, row 5)
|
||||
|
||||
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.1 and D3.
|
||||
Base: `1c5f6bc3` on `refactor`. Nothing here is committed; the candidate is
|
||||
the working tree, pinned by the hashes below.
|
||||
|
||||
Revision 1 was `286c3ad5`, kept as `BACKEND-r1-286c3ad5.md`. Filbert
|
||||
reviewed it and asked for a revision
|
||||
(`agents/filbert/work/chat-02-backend-review-2026-09-26.md`, `27d64e14…`).
|
||||
Darkwing approved the routes in it
|
||||
(`agents/darkwing/work/chat-02-routes-review-2026-09-26.md`, `07b10ad1…`)
|
||||
with two nonblocking notes. §0 answers both reviews.
|
||||
|
||||
Reviewers (Sage's order):
|
||||
- Filbert: the code, all nine files. He asked to review the delta.
|
||||
- Darkwing: the two board routes. `serve.mjs` and `serve.test.mjs` changed
|
||||
for his notes, so the route delta needs his look.
|
||||
|
||||
The Console (`packages/webui`) is the next step and is not in this packet.
|
||||
The four WebUI browser failures Filbert saw in the shared tree come from my
|
||||
unpinned Console edits to `app.js` and `index.html`. They are Console work.
|
||||
|
||||
## 0. Revision 2 changes
|
||||
|
||||
### Filbert, required
|
||||
|
||||
1. **Branch ids are stable.**
|
||||
- Roots are entries whose parent is null or absent, in file order. The
|
||||
first root's line is `main`. `main` exists even in a file with no
|
||||
entries, as an empty branch with a null leaf.
|
||||
- At a fork the earliest child in file order continues its parent's
|
||||
branch. Each later child starts a branch named `b.<its id>`. Each later
|
||||
root (Pi's `resetLeaf`) starts `b.<its id>` too.
|
||||
- Growth never renames a branch. `page.branch` always equals the cursor's
|
||||
branch, and `open({branch})` with a name taken before growth still
|
||||
works.
|
||||
- The default branch is the one ending at Pi's default leaf, the last
|
||||
entry in file order. `view.defaultBranch` names it; `view.branches`
|
||||
flags it with `isDefault`.
|
||||
- Follow rebuilds the cursor's own branch from a fresh snapshot. It
|
||||
refuses `source-replaced` with reconcile if the branch is gone, is
|
||||
shorter than the parts served, or the ids of those parts differ.
|
||||
- Tests: F12 "two leaves" (branches `main` and `b.o4`, one branch value
|
||||
across follows, the pre-growth name still opens, the `main` view picks
|
||||
up its growth), F12 "a second root", F12 "a follow refuses when an
|
||||
appended duplicate id changes the branch's earlier parts", F1 "a follow
|
||||
stays on its branch".
|
||||
- Your `branch.mjs`: open and follow both give `main`, every entry is
|
||||
labelled `main`, and reopening with the first branch id works.
|
||||
2. **The bridge is gone.**
|
||||
- A missing parent stops the history with a `missing-parent` notice. When
|
||||
malformed lines sit just before the entry, the notice names them: "Line
|
||||
N could not be read and may have held it." Those lines are not repeated
|
||||
as separate notices.
|
||||
- The history before the gap is its own leaf branch and reads normally.
|
||||
- Tests: F1 "a missing parent stops the history with a notice that names
|
||||
the unreadable lines", F1 "an unreadable fork is never merged into
|
||||
another branch's history" (your case).
|
||||
- Your `bridge.mjs`: `b.y` shows only the notice and `leaf`.
|
||||
3. **`EACCES` and `EPERM` on a directory component become `unreadable`.**
|
||||
- `lstatOrNull` maps them through `denied()`. The root is refused on its
|
||||
own row; the catalogue and opens under other roots are unaffected.
|
||||
- Test: "a seat directory without search permission refuses that root,
|
||||
not the catalogue", with the bad root first and then last.
|
||||
- Your `perm.mjs`: the catalogue returns the good root's row and lists
|
||||
`bad` in `refusedRoots` as `unreadable`. The open of an unknown id walks
|
||||
both roots and returns an `unknown-conversation` refusal, not a throw.
|
||||
|
||||
### Filbert, small
|
||||
|
||||
- Redacted thinking (`redacted: true`) is `unavailable` with empty text,
|
||||
whatever the `thinking` field says. Test: the mapping test.
|
||||
- A relative header `cwd` is refused as `foreign-project`. Test: F10
|
||||
`relative.jsonl`.
|
||||
- Malformed notices appear when a file has no valid entry. Each malformed
|
||||
line keeps its own notice at its file position, on every branch, including
|
||||
after the leaf. Test: F1 "a file whose entries are all unreadable".
|
||||
|
||||
### Darkwing, nonblocking
|
||||
|
||||
1. A cursor call without `branch` is now 400 ("a cursor call repeats the
|
||||
page's branch"), and the header comment says so.
|
||||
2. `REFUSAL_STATUS` is exported. A new test scans the reader's three source
|
||||
files for every `new Refusal("<code>"` plus `UNSUPPORTED_HARNESS` and
|
||||
fails on a missing or stale entry. Its first run found `unavailable`
|
||||
falling to 422; it is now 404. `unknown-actor` (403) and
|
||||
`unsupported-purpose` (422) are now explicit.
|
||||
|
||||
Route delta against revision 1: `git diff 34777c56 -- packages/control-board`
|
||||
shows the whole change; against Darkwing's pins it is the header comment,
|
||||
`export` on `REFUSAL_STATUS` with three new entries, one line in
|
||||
`conversationQuery`, the new test and branch names in expectations
|
||||
(`main`, `b.e5`, `b.e1`).
|
||||
|
||||
## 1. Candidate hashes
|
||||
|
||||
```
|
||||
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
|
||||
1ea8c8c093cb726773bfa55c354126cf4b1720affa6d0e9cd0845570cbfb735e packages/conversation/README.md
|
||||
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
|
||||
20e781240846298fa65f9a8226b5e1ee1e81338b81229334666d845c17c99ac1 packages/conversation/src/pi.mjs
|
||||
72c3255bca7a894f6484b3224aabf054d40a0db78cbe46a6c492ed385333b8ad packages/conversation/src/reader.mjs
|
||||
10a1ff9e91c1f1e4684fc38c5bca83c50d79bb1f459d269517396545a269203a packages/conversation/src/safe-fs.mjs
|
||||
78b7719b0c183cc1d9fedf009fb8a5e4dae34a30d1b39f931824016cc07fa86b packages/conversation/tests/reader.test.mjs
|
||||
d62720dcf1bd43ce9412356a04b5248a85aec59dd78194887e3c38010c0aa2f3 packages/control-board/src/serve.mjs
|
||||
d38aa2b209e0fb22ab3c52c3cb26a8f05e9e269e567d40b7e6ad88430c593f4a packages/control-board/tests/serve.test.mjs
|
||||
```
|
||||
|
||||
Unchanged from revision 1: `package.json` and `parts.mjs`. The first seven
|
||||
files are new. `serve.mjs` and `serve.test.mjs` are diffs against the base.
|
||||
`scan.mjs` is unchanged. D3 allowed edits there, but none were needed.
|
||||
|
||||
## 2. What it does
|
||||
|
||||
`packages/conversation/README.md` is the reference: API, sources, safe open,
|
||||
parser rules, branch names, page limits, snapshot, epoch and cursor rules,
|
||||
follow, the refusal table and costs. In short:
|
||||
|
||||
- `rootsFromSpecs` turns the board's repository specs into roots, using
|
||||
registrations only as hints. `createReader` serves `catalogue`, `open` and
|
||||
`next`.
|
||||
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
|
||||
Console needs beyond CHAT-01 goes in `view`.
|
||||
- The board serves `GET /api/conversations` (no parameters) and
|
||||
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
|
||||
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
|
||||
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
|
||||
headers.
|
||||
- Status map:
|
||||
- 404 for an unknown conversation or branch, and `unavailable`;
|
||||
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
|
||||
- 403 for `unsafe-path`, `foreign-project`, `unreadable` and
|
||||
`unknown-actor`;
|
||||
- 422 for the rest, each listed in `REFUSAL_STATUS`;
|
||||
- 400 for any query parameter other than `id`, `branch` or `cursor`, a
|
||||
repeated one, a value that fails the id pattern, or a cursor without a
|
||||
branch;
|
||||
- 500 with a fixed message for an exception, with details only on stderr.
|
||||
|
||||
## 3. Choices and deviations to review
|
||||
|
||||
1. **Catalogue rows are a summary shape, not CHAT-01 `catalogueItem`.** The
|
||||
row is: conversation, seat, project, harness, history, title, readOnly,
|
||||
`controlMode: "unavailable"`, the three separate time fields,
|
||||
availability, `unsupportedReason` and refusal. `catalogueItem` carries
|
||||
binding and control fields that belong to CHAT-03. I did not want to fill
|
||||
them with placeholders that look authoritative.
|
||||
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
|
||||
have no `sessionFile` field. The brief §2.1 rule is applied to the
|
||||
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
|
||||
must all match. A registration never adds a root or names a file (F9).
|
||||
3. **Placeholder rows for non-Pi seats.** A seat on another harness has no Pi
|
||||
directory, so the board has no spec for it. A registration adds one
|
||||
`unsupported-harness` row when its `sessionsDir` is the standard
|
||||
directory under a project root that is already approved. Its directory is
|
||||
never read. Rocko (`claude-code`) is the live case (F15).
|
||||
4. **An epoch id is comparable only within one cursor chain.**
|
||||
`sourceEpoch = "e-" + sha256(dev:ino:digest)`, taken at open and carried
|
||||
forward while the prefix verifies. Two opens of an unchanged file give the
|
||||
same value. After growth, a new open gets a new value for the same epoch,
|
||||
while the old chain keeps its own. Only a new inode, a shorter file or a
|
||||
changed prefix is a new epoch, and that is refused.
|
||||
5. **Follow stays on its branch.** On the last page, `follow` replaces
|
||||
`cursor`.
|
||||
- `next` with it re-verifies the pinned prefix, takes a fresh snapshot and
|
||||
rebuilds the cursor's branch by name.
|
||||
- It returns the parts after the ones already served. The branch must
|
||||
still exist and the ids of the served parts must be unchanged;
|
||||
otherwise `source-replaced` with reconcile.
|
||||
- If Pi's default moved to another branch, the page stays on the old
|
||||
branch and `view.defaultBranch` names the new one. The Console decides
|
||||
what to show; nothing switches silently.
|
||||
6. **A missing parent stops the history.** There is no bridge. The notice
|
||||
names any malformed lines just before the entry, since one of them may
|
||||
have held the parent. The history before the gap is its own branch. A
|
||||
malformed line keeps its notice at its file position on every branch, so
|
||||
a branch's served prefix does not change as the file grows.
|
||||
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing, a
|
||||
directory component or a file open becomes a per-row 403, so one
|
||||
unreadable path cannot fail the whole catalogue.
|
||||
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
|
||||
18.6 MB.
|
||||
9. **Every page re-hashes the whole pinned prefix.** This makes detection
|
||||
simple and total, at a cost measured in §4.3.
|
||||
10. **Refusal code names** not already in CHAT-01 are CHAT-02 values, like
|
||||
`unsupported-harness` (D2): `unsafe-path`, `foreign-project`,
|
||||
`unreadable`, `not-a-pi-session`, `incomplete-header`, `too-large`,
|
||||
`unknown-branch`, `unavailable`, `unknown-actor` and
|
||||
`unsupported-purpose`.
|
||||
|
||||
## 4. Evidence
|
||||
|
||||
All runs are on the candidate hashes above.
|
||||
|
||||
### 4.1 Suites and contract checks
|
||||
|
||||
- In `/tmp/dewey-chat02/overlay`, a `git archive` of `1c5f6bc3` with only
|
||||
the nine files overlaid: `node --test --test-concurrency=1` over the
|
||||
conversation, control-board, webui and seat suites gives 181 tests, 181
|
||||
pass (29, 124, 9 and 19).
|
||||
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
|
||||
`chat-01c/check.mjs` all exit 0.
|
||||
|
||||
### 4.2 Fixture map (brief §2.1)
|
||||
|
||||
| # | Test |
|
||||
|---|---|
|
||||
| F1 | five tests in `reader.test.mjs`: malformed notice in place (with a hostile id); missing parent names the unreadable lines; an unreadable fork is never merged; a follow stays on its branch; an all-malformed file shows a notice per line |
|
||||
| F12 | three tests: two leaves with stable names across growth; a follow that refuses when an appended duplicate id changes served parts; a second root |
|
||||
| F2–F11, F13–F15 | one named test each in `reader.test.mjs` |
|
||||
| F16 | `serve.test.mjs` §12: foreign Host and cross-origin Origin on both routes give 403; a spy reader records zero calls, and no `access-control-*` header is sent |
|
||||
| F17 | every reader call in `reader.test.mjs` runs inside a fingerprint of the whole fixture tree: size, SHA-256, mtime (ns), (dev, ino), mode and every directory listing. The route test in `serve.test.mjs` does the same. Files no read may touch are mode 000. |
|
||||
|
||||
Additional coverage: mapping of every Pi entry type and role (including
|
||||
redacted thinking), pagination at 100 parts and at the byte cap,
|
||||
surrogate-safe fragments, unknown and empty and non-Pi files, an unreadable
|
||||
file or root, a seat directory at mode 000, every refusal code having an
|
||||
HTTP status, and CHAT-01 schema validation of every page and cursor the
|
||||
tests produce (Python `jsonschema`).
|
||||
|
||||
### 4.3 Real data (read-only, this checkout)
|
||||
|
||||
- Catalogue: 19 rows in 75 ms, no refused roots. There are 18 available Pi
|
||||
conversations across darkwing, dewey, filbert, researcher and sage, plus
|
||||
rocko as `unsupported-harness`.
|
||||
- Reading every page of every conversation gave 102 pages and 8693 entries,
|
||||
with no refusals. Every page is on branch `main`. The slowest conversation
|
||||
took 798 ms (662 ms in revision 1, on files that have grown since; I did
|
||||
not isolate the difference). The largest file is 18.6 MB.
|
||||
- 186 pages and cursors sampled from that run: 0 schema-invalid.
|
||||
- Over HTTP, through a temporary board on port 0 with a temporary `boardDir`
|
||||
(the live board was not touched): 19 rows and 102 pages in 1487 ms. Rocko
|
||||
returns 422 `unsupported-harness`.
|
||||
- Scripts and output: `evidence/backend/smoke.mjs`, `smoke-stats.txt`,
|
||||
`http-real.mjs`, `http-out.txt`.
|
||||
|
||||
### 4.4 Mutation testing
|
||||
|
||||
Mutations were run in scratch copies under `/tmp/dewey-chat02/`, never in
|
||||
the served tree. The route scratch is a full copy of the checkout (without
|
||||
`.git`, `agents`, `v1` and `skills/aws-*`), so each mutant's failures are
|
||||
real test failures, not import errors. Scripts and full output:
|
||||
`evidence/backend/mutate.py`, `mutate-board.py`, `mutate-backend.txt` and
|
||||
`mutate-routes.txt`.
|
||||
|
||||
Reader: 38 of 39 caught.
|
||||
|
||||
| Mutant | Caught by |
|
||||
|---|---|
|
||||
| no O_NOFOLLOW and no lstat symlink check | F7, F8 |
|
||||
| directory components unchecked | F7 |
|
||||
| listing follows symlinks | F7, F8 |
|
||||
| no prefix digest check | F4 |
|
||||
| no dev/ino check | F3 |
|
||||
| no shorter check | **not caught, equivalent** (below) |
|
||||
| cwd always in project | F10 |
|
||||
| relative cwd accepted | F10 |
|
||||
| no character cap | F14, schema |
|
||||
| no page byte cap | F14 |
|
||||
| no block cap | F14, schema |
|
||||
| no foreign check | F6 |
|
||||
| no branch binding | F6 |
|
||||
| no expiry | F6 |
|
||||
| no parentSession notice | F11 |
|
||||
| branch parameter ignored | F12 two leaves, F12 second root, F1 missing parent |
|
||||
| unknown branch served | F12 two leaves |
|
||||
| registration harness ignored | F15 |
|
||||
| no placeholder roots | F15 |
|
||||
| malformed notices dropped | four F1 tests |
|
||||
| bridge restored | F1 missing parent, F1 unreadable fork, F1 follow |
|
||||
| missing-parent lines not named | F1 missing parent, F1 unreadable fork, F1 follow |
|
||||
| trailing malformed notices only on the default branch | F1 missing parent, F1 follow |
|
||||
| branch named by its leaf | all three F12 tests, two F1 tests |
|
||||
| later child continues the branch | F12 two leaves |
|
||||
| first root not `main` | all three F12 tests, two F1 tests |
|
||||
| no empty `main` | F1 all malformed, unknown/empty |
|
||||
| redacted thinking shown | mapping |
|
||||
| `EACCES` on a component rethrown | seat directory at mode 000 |
|
||||
| `incomplete` never set | F2, F5 |
|
||||
| `next` re-reads fresh instead of pinned | F2, F5, F12, F1 follow |
|
||||
| follow skips the history check | F12 duplicate id |
|
||||
| follow skips the id digest | F12 duplicate id |
|
||||
| follow reads the default branch | F12 two leaves, F1 follow |
|
||||
| catalogue writes a file | F17 in six fixtures |
|
||||
| wrong thinking visibility | mapping |
|
||||
| raw tool ids | mapping, schema |
|
||||
| id namespaces dropped | F1 |
|
||||
| surrogate pair split | fragments |
|
||||
|
||||
Removed since revision 1: "no bridge" and "follow ignores branching", whose
|
||||
behaviour no longer exists.
|
||||
|
||||
The equivalent mutant: removing the "file is shorter" check changes nothing
|
||||
observable. A shorter file cannot supply the pinned length, so the prefix
|
||||
digest differs and `source-replaced` still follows. The check stays because
|
||||
it gives the refusal without hashing a truncated read.
|
||||
|
||||
Board routes: 11 of 11 caught. The mutants were: routes before the guard, no
|
||||
`nosniff`, every refusal 422, no query validation, unknown parameters
|
||||
allowed, `reconcile` dropped, cursor ignored, registrations ignored, a
|
||||
cursor without branch served, `unavailable` falling to 422, and a CORS
|
||||
header sent.
|
||||
|
||||
## 5. Known limits
|
||||
|
||||
- **No `openat` in Node.** A directory component swapped between the checks
|
||||
and the open is caught by the re-check after the open, not prevented.
|
||||
Nothing is read from a descriptor whose (dev, ino) does not match.
|
||||
- **The `fstat` mismatch branch of F8** is covered by design, not by a
|
||||
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
|
||||
plain-file swap between `lstat` and `open` needs a race the suite cannot
|
||||
schedule.
|
||||
- **An in-place rewrite with the same inode** is detected by the digest of
|
||||
the pinned prefix, as the brief requires. Growth after the pin is not a
|
||||
change.
|
||||
- **Follow compares entry ids, not content, for the parts already served.**
|
||||
The pinned byte prefix is still re-hashed, so a rewrite of served bytes is
|
||||
caught. An appended entry that reuses a served id is caught when it moves
|
||||
that entry off the branch or puts another id in its place (F12 duplicate
|
||||
id, both cases). If it keeps the same id in the same position with new
|
||||
content, follow does not notice: the parts already served keep the old
|
||||
text, while a fresh open shows the new one. Pi never writes duplicate
|
||||
ids.
|
||||
- **Cost.** Each page reads and hashes the whole pinned prefix; §4.3 has the
|
||||
cost on real files.
|
||||
- **One actor.** Cursors live in memory and are lost when the board restarts;
|
||||
the refusal is `cursor-unknown` with reconcile. `local-operator` is the
|
||||
only actor, which is not multi-actor safety (CHAT-04R).
|
||||
- **Claude history** waits for B1 and CHAT-03 (D2).
|
||||
|
||||
## 6. After review
|
||||
|
||||
- Commit condition (Darkwing): `serve.mjs` imports
|
||||
`../../conversation/src/reader.mjs` at load, so `packages/conversation/`
|
||||
lands in the same commit as the routes or an earlier one, after Filbert's
|
||||
approval.
|
||||
- Pushing goes through Sage with Jason's word. The live board needs a restart
|
||||
after the commit to serve the routes.
|
||||
- The WebUI proxy allowlist for the two routes is Console work and is not in
|
||||
this packet.
|
||||
@@ -0,0 +1,3 @@
|
||||
rows 19 researcher:available dewey:available sage:available darkwing:available filbert:available darkwing:available filbert:available filbert:available filbert:available filbert:available filbert:available darkwing:available filbert:available dewey:available darkwing:available darkwing:available darkwing:available darkwing:available rocko:unsupported
|
||||
pages 102 ms 1487
|
||||
unsupported 422 {"error":"this harness has no history reader yet","refusal":{"code":"unsupported-harness","reconcile":false}}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { startServer } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/serve.mjs";
|
||||
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
const specs = discoverRepoAgents("/mnt/storage/src/mosaic-stack");
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: mkdtempSync("/tmp/dewey-chat02/board-"), seatsDir: homedir() + "/.mosaic-dev/seats", isAlive: () => true, page: "x" });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const cat = await (await fetch(base + "/api/conversations")).json();
|
||||
console.log("rows", cat.conversations.length, cat.conversations.map(c => `${c.seat}:${c.availability}`).join(" "));
|
||||
const big = cat.conversations.filter(c => c.availability === "available");
|
||||
let pages = 0, t = Date.now();
|
||||
for (const c of big) {
|
||||
let r = await (await fetch(`${base}/api/conversation?id=${c.conversation}`)).json();
|
||||
pages++;
|
||||
while (r.page?.hasMore) { r = await (await fetch(`${base}/api/conversation?id=${c.conversation}&branch=${r.page.branch}&cursor=${r.page.nextCursor}`)).json(); pages++; if (!r.ok) { console.log("refused", r); break; } }
|
||||
}
|
||||
console.log("pages", pages, "ms", Date.now() - t);
|
||||
const u = cat.conversations.find(c => c.availability === "unsupported");
|
||||
const ur = await fetch(`${base}/api/conversation?id=${u.conversation}`); console.log("unsupported", ur.status, JSON.stringify(await ur.json()));
|
||||
server.close();
|
||||
@@ -0,0 +1,39 @@
|
||||
CAUGHT no O_NOFOLLOW + no lstat symlink -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
|
||||
CAUGHT dir components unchecked -> F7: a symlinked file and a symlinked directory component are refused,
|
||||
CAUGHT listing follows symlinks -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
|
||||
CAUGHT no prefix digest check -> F4: a same-inode rewrite of the prefix refuses old cursors with reconc
|
||||
CAUGHT no dev/ino check -> F3: a replaced file
|
||||
MISSED no shorter check ->
|
||||
CAUGHT cwd always in project -> F10: a header cwd naming another project is refused
|
||||
CAUGHT relative cwd accepted -> F10: a header cwd naming another project is refused
|
||||
CAUGHT no char cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
|
||||
CAUGHT no page byte cap -> F14: long strings split into fragments and parts, reassemble exactly,
|
||||
CAUGHT no block cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
|
||||
CAUGHT no foreign check -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
|
||||
CAUGHT no branch binding -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
|
||||
CAUGHT no expiry -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
|
||||
CAUGHT no parentSession notice -> F11: parentSession renders with a marker and the parent is never opene
|
||||
CAUGHT branch param ignored -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a missing parent stops the history with a notice that names the un
|
||||
CAUGHT unknown branch served -> F12: two leaves: the default leaf is shown and the other branch reads
|
||||
CAUGHT registration harness ignored -> F15: a Claude seat is an unsupported-harness placeholder whose directo
|
||||
CAUGHT no placeholder roots -> F15: a Claude seat is an unsupported-harness placeholder whose directo
|
||||
CAUGHT malformed notices dropped -> F1: a file whose entries are all unreadable shows a notice per line; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a malformed line is an unavailable part at its position, and readi; F1: a missing parent stops the history with a notice that names the un
|
||||
CAUGHT bridge restored -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
|
||||
CAUGHT missing-parent lines not named -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
|
||||
CAUGHT trailing malformed only on default -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un
|
||||
CAUGHT branch named by leaf -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
|
||||
CAUGHT later child continues branch -> F12: two leaves: the default leaf is shown and the other branch reads
|
||||
CAUGHT first root not main -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
|
||||
CAUGHT no empty main -> F1: a file whose entries are all unreadable shows a notice per line; unknown conversations, empty files and non-Pi files refuse
|
||||
CAUGHT redacted thinking shown -> native entries map to blocks: tools, thinking, bash, notices, ids that
|
||||
CAUGHT EACCES on a component rethrown -> a seat directory without search permission refuses that root, not the
|
||||
CAUGHT incomplete never set -> F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at the pin
|
||||
CAUGHT next re-reads fresh -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at th
|
||||
CAUGHT follow skips history check -> F12: a follow refuses when an appended duplicate id changes the branch
|
||||
CAUGHT follow skips the id digest -> F12: a follow refuses when an appended duplicate id changes the branch
|
||||
CAUGHT follow reads the default branch -> F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea
|
||||
CAUGHT catalogue writes a file -> F10: a header cwd naming another project is refused; F15: a Claude seat is an unsupported-harness placeholder whose directo; F1: a malformed line is an unavailable part at its position, and readi; F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlin
|
||||
CAUGHT thinking visibility wrong -> native entries map to blocks: tools, thinking, bash, notices, ids that
|
||||
CAUGHT raw tool ids -> every page and cursor is a valid CHAT-01 record; native entries map to blocks: tools, thinking, bash, notices, ids that
|
||||
CAUGHT id namespaces dropped -> F1: a malformed line is an unavailable part at its position, and readi
|
||||
CAUGHT surrogate split -> fragments never cut a surrogate pair and keep an empty string
|
||||
@@ -0,0 +1,30 @@
|
||||
import subprocess, os
|
||||
S="/tmp/dewey-chat02/scratch-board"
|
||||
f=os.path.join(S,"packages/control-board/src/serve.mjs")
|
||||
orig=open(f).read()
|
||||
M=[
|
||||
("conversation routes before the guard",[(""" const refused = foreignRequest(req);""",""" { const u0 = new URL(req.url, "http://localhost"); if (u0.pathname.startsWith("/api/conversation")) { let o; try { o = conversationResponse(reader, u0); } catch { o = { status: 500, body: {} }; } return sendConversationJson(res, o.status, o.body); } }
|
||||
const refused = foreignRequest(req);""")]),
|
||||
("no nosniff",[(', "x-content-type-options": "nosniff"','')]),
|
||||
("all refusals 422",[("status: REFUSAL_STATUS[out.refusal.code] ?? 422","status: 422")]),
|
||||
("no query validation",[(" if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` };\n","")]),
|
||||
("unknown params allowed",[(""" if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` };\n""","")]),
|
||||
("reconcile dropped",[("refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile }","refusal: { code: out.refusal.code }")]),
|
||||
("cursor ignored",[(" const out = query.cursor\n"," const out = false\n")]),
|
||||
("registrations ignored",[("rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations)","rootsFromSpecs(specs, [])")]),
|
||||
("cursor without branch served",[(" if (out.cursor && !out.branch) return"," if (false) return")]),
|
||||
("unavailable falls to 422",[(" unavailable: 404,\n","")]),
|
||||
("CORS header sent",[('"x-content-type-options": "nosniff" });','"x-content-type-options": "nosniff", "access-control-allow-origin": "*" });')]),
|
||||
]
|
||||
for name,reps in M:
|
||||
src=orig
|
||||
ok=True
|
||||
for a,b in reps:
|
||||
if a not in src: print("NOT APPLIED",name); ok=False; break
|
||||
src=src.replace(a,b,1)
|
||||
if not ok: continue
|
||||
open(f,"w").write(src)
|
||||
r=subprocess.run(["node","--test","--test-concurrency=1","tests/serve.test.mjs"],cwd=os.path.join(S,"packages/control-board"),capture_output=True,text=True,timeout=600)
|
||||
fails=sorted(set(l.strip()[2:].split(" (")[0][:60] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
|
||||
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(fails))
|
||||
open(f,"w").write(orig)
|
||||
@@ -0,0 +1,11 @@
|
||||
CAUGHT conversation routes before the guard -> conversation routes
|
||||
CAUGHT no nosniff -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT all refusals 422 -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT no query validation -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT unknown params allowed -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT reconcile dropped -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT cursor ignored -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT registrations ignored -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT cursor without branch served -> conversation routes: catalogue, first page, next page and fo
|
||||
CAUGHT unavailable falls to 422 -> every refusal code the reader can raise has an HTTP status
|
||||
CAUGHT CORS header sent -> conversation routes: catalogue, first page, next page and fo
|
||||
@@ -0,0 +1,59 @@
|
||||
import subprocess, sys, shutil, os
|
||||
S="/tmp/dewey-chat02/scratch/packages/conversation"
|
||||
M=[
|
||||
("no O_NOFOLLOW + no lstat symlink","src/safe-fs.mjs",[("constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK","constants.O_RDONLY"),(' if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink");\n',''),(' if (!before.isFile()) throw',' if (false) throw'),("if (!st.isFile() || st.dev !== before.dev","if (st.dev !== before.dev")]),
|
||||
("dir components unchecked","src/safe-fs.mjs",[(' if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink");\n',''),(' if (!st.isDirectory()) throw',' if (false) throw')]),
|
||||
("listing follows symlinks","src/safe-fs.mjs",[(' else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" });\n','')]),
|
||||
("no prefix digest check","src/reader.mjs",[('if (pinned.digest !== state.digest) throw','if (false) throw')]),
|
||||
("no dev/ino check","src/reader.mjs",[('if (pinned.file.dev !== state.dev || pinned.file.ino !== state.ino) throw','if (false) throw'),('if (fresh.file.dev !== state.dev || fresh.file.ino !== state.ino || fresh.length < state.length) throw','if (fresh.length < state.length) throw')]),
|
||||
("no shorter check","src/reader.mjs",[('if (pinned.shorter) throw','if (false) throw')]),
|
||||
("cwd always in project","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' return true;')]),
|
||||
("relative cwd accepted","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' if (typeof cwd !== "string" || !cwd) return false;')]),
|
||||
("no char cap","src/parts.mjs",[("chars + 1 > LIMITS.chars ||","false ||")]),
|
||||
("no page byte cap","src/parts.mjs",[("if (bytes + add > LIMITS.pageBytes) break;","")]),
|
||||
("no block cap","src/parts.mjs",[("current.length === LIMITS.blocks ||","false ||")]),
|
||||
("no foreign check","src/reader.mjs",[("if (actor !== record.actor || purpose !== record.purpose || conversation !== record.conversation || branch !== record.branch) {","if (false) {")]),
|
||||
("no branch binding","src/reader.mjs",[("|| branch !== record.branch) {",") {")]),
|
||||
("no expiry","src/reader.mjs",[("if (Date.parse(record.expiresAt) <= now()) {","if (false) {")]),
|
||||
("no parentSession notice","src/pi.mjs",[(" if (parsed.header.parentSession !== undefined && parsed.header.parentSession !== null) {\n units.push"," if (false) {\n units.push")]),
|
||||
("branch param ignored","src/reader.mjs",[("const built = build(snap, found.root, branch ?? null, conversation);","const built = build(snap, found.root, null, conversation);")]),
|
||||
("unknown branch served","src/reader.mjs",[("if (!parsed.branches.has(branch)) return null;","if (false) return null;")]),
|
||||
("registration harness ignored","src/reader.mjs",[("unsupportedReason: harness !== null && harness !== HISTORY ? UNSUPPORTED_HARNESS : null,","unsupportedReason: null,")]),
|
||||
("no placeholder roots","src/reader.mjs",[(" if (!reg || reg.layout !== \"repo\""," if (true || reg.layout !== \"repo\"")]),
|
||||
("malformed notices dropped","src/pi.mjs",[('out.push({ notice: "malformed", lines: [pending[mi++].line] });','mi++;')]),
|
||||
("bridge restored","src/pi.mjs",[(' path.push({ notice: "missing-parent", lines: lost });\n break;',' const prev = r.index > 0 ? parsed.byId.get(parsed.entries[r.index - 1].entry.id) : null;\n if (lost.length && prev) { path.push({ notice: "missing-parent", lines: lost }); r = prev; continue; }\n path.push({ notice: "missing-parent", lines: lost });\n break;')]),
|
||||
("missing-parent lines not named","src/pi.mjs",[("const lost = parsed.malformed.filter((m) => m.after === r.index - 1).map((m) => m.line);","const lost = [];")]),
|
||||
("trailing malformed only on default","src/pi.mjs",[(" flushBefore(Infinity);\n return out;"," if (leaf === parsed.defaultLeaf) flushBefore(Infinity);\n return out;")]),
|
||||
("branch named by leaf","src/pi.mjs",[(" const branchOf = (leaf) => {"," const branchOf = (leaf) => branchName(leaf);\n const unused = (leaf) => {")]),
|
||||
("later child continues branch","src/pi.mjs",[("if (children.get(p.entry.id)[0] !== r) return branchName(r);","if (children.get(p.entry.id).at(-1) !== r) return branchName(r);")]),
|
||||
("first root not main","src/pi.mjs",[("return r === firstRoot ? MAIN : branchName(r);","return branchName(r);")]),
|
||||
("no empty main","src/pi.mjs",[(" if (!branches.size) branches.set(MAIN, null);\n","")]),
|
||||
("redacted thinking shown","src/pi.mjs",[('const t = b.redacted === true ? "" : typeof b.thinking','const t = typeof b.thinking')]),
|
||||
("EACCES on a component rethrown","src/safe-fs.mjs",[('throw denied(err, "a session path component");','throw err;')]),
|
||||
("incomplete never set","src/reader.mjs",[("incomplete: buf.length > length };","incomplete: false };")]),
|
||||
("next re-reads fresh","src/reader.mjs",[("const pinned = snapshot(state.root, state.name, state.length);","const pinned = { ...snapshot(state.root, state.name), length: state.length };")]),
|
||||
("follow skips history check","src/reader.mjs",[("if (!built || built.entries.length < state.offset || idsDigest(built.entries, state.offset) !== state.prefix) {","if (!built) {")]),
|
||||
("follow skips the id digest","src/reader.mjs",[("|| idsDigest(built.entries, state.offset) !== state.prefix) {",") {")]),
|
||||
("follow reads the default branch","src/reader.mjs",[("const built = build(fresh, state.root, state.branch, conversation);","const built = build(fresh, state.root, null, conversation);")]),
|
||||
("catalogue writes a file","src/reader.mjs",[(" function catalogue() {"," function catalogue() {\n for (const r of listRoots()) { try { require_(r); } catch {} }")]),
|
||||
("thinking visibility wrong","src/pi.mjs",[('visibility: t ? "permitted-visible" : "unavailable"','visibility: "permitted-visible"')]),
|
||||
("raw tool ids","src/pi.mjs",[("call: safeId(b.id), name: safeId(b.name)","call: String(b.id), name: String(b.name)")]),
|
||||
("id namespaces dropped","src/pi.mjs",[("id: `n.${e.id}`","id: e.id")]),
|
||||
("surrogate split","src/parts.mjs",[("const width = cp > 0xffff ? 2 : 1;","const width = 1;")]),
|
||||
]
|
||||
orig={}
|
||||
for f in ["src/safe-fs.mjs","src/reader.mjs","src/parts.mjs","src/pi.mjs"]:
|
||||
orig[f]=open(os.path.join(S,f)).read()
|
||||
for name,f,reps in M:
|
||||
src=orig[f]
|
||||
for a,b in reps:
|
||||
if a not in src: print("NOT APPLIED",name,repr(a[:60])); break
|
||||
src=src.replace(a,b)
|
||||
else:
|
||||
if name=="catalogue writes a file":
|
||||
src=src.replace("require_(r);",'(await_import => 0)(); (require_fs => 0)(); import_fs.writeFileSync(r.dir + "/x.tmp", "")').replace('import { createHash, randomBytes } from "node:crypto";','import { createHash, randomBytes } from "node:crypto";\nimport * as import_fs from "node:fs";')
|
||||
open(os.path.join(S,f),"w").write(src)
|
||||
r=subprocess.run(["node","--test","tests/"],cwd=S,capture_output=True,text=True,timeout=600)
|
||||
fails=sorted(set(l.strip()[2:].split(" (")[0] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
|
||||
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(x[:70] for x in fails)[:300])
|
||||
open(os.path.join(S,f),"w").write(orig[f])
|
||||
@@ -0,0 +1,2 @@
|
||||
{ convs: 18, pages: 102, entries: 8693, refusals: {}, maxMs: 798 }
|
||||
catalogue 19 rows in 75 ms, refusedRoots []; 186 sampled pages and cursors: 0 schema-invalid; every page branch: main (102)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
|
||||
import { rootsFromSpecs, createReader } from "/mnt/storage/src/mosaic-stack/packages/conversation/src/reader.mjs";
|
||||
import { readRegistration, LAYOUTS } from "/mnt/storage/src/mosaic-stack/packages/seat/src/seat.mjs";
|
||||
import { readdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
const seats = homedir() + "/.mosaic-dev/seats";
|
||||
const regs = [];
|
||||
import { loadRegistrations } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs"; regs.push(...loadRegistrations(seats).registrations);
|
||||
const roots = rootsFromSpecs(discoverRepoAgents("/mnt/storage/src/mosaic-stack"), regs);
|
||||
console.log(roots.map(r => `${r.seat} ${r.harness} ${r.unsupportedReason} ${r.engineStartedAt}`).join("\n"));
|
||||
const reader = createReader({ roots });
|
||||
let t = Date.now();
|
||||
const cat = reader.catalogue();
|
||||
console.log("catalogue", cat.conversations.length, "ms", Date.now() - t, "refusedRoots", JSON.stringify(cat.refusedRoots));
|
||||
const byAvail = {}; for (const c of cat.conversations) byAvail[c.availability + ":" + c.refusal] = (byAvail[c.availability + ":" + c.refusal] ?? 0) + 1;
|
||||
console.log(byAvail);
|
||||
console.log(cat.conversations.slice(0, 3));
|
||||
const pages = [];
|
||||
let stats = { convs: 0, pages: 0, entries: 0, refusals: {} , maxMs: 0};
|
||||
for (const c of cat.conversations.filter(c => c.availability === "available")) {
|
||||
t = Date.now();
|
||||
let r = reader.open({ conversation: c.conversation });
|
||||
if (!r.ok) { stats.refusals[r.refusal.code] = (stats.refusals[r.refusal.code] ?? 0) + 1; continue; }
|
||||
stats.convs++;
|
||||
let n = 0;
|
||||
while (true) {
|
||||
stats.pages++; stats.entries += r.page.entries.length; n++;
|
||||
if (pages.length < 400) pages.push(r.page);
|
||||
if (r.cursor) pages.length < 400 && pages.push(r.cursor);
|
||||
if (!r.page.hasMore) break;
|
||||
r = reader.next({ cursor: r.page.nextCursor, conversation: c.conversation, branch: r.page.branch });
|
||||
if (!r.ok) { console.log("next refused", r.refusal); break; }
|
||||
}
|
||||
stats.maxMs = Math.max(stats.maxMs, Date.now() - t);
|
||||
}
|
||||
console.log(stats);
|
||||
writeFileSync("/tmp/dewey-chat02/pages.json", JSON.stringify(pages));
|
||||
Reference in New Issue
Block a user