Compare commits

..
Author SHA1 Message Date
jason.woltje 2d0d407ba6 row 269 (264b, #1774): the M6 halt test halts after mayLaunch answers
A halt inside the op reaches the bus's own fence first (runtime.mjs:564-566,
processingWork at :352-356), so the launcher refused limit-check-failed
(launcher.mjs:389-391) before its recheck admission() at :397. First-run red of
a new test on 6d9ab3503 early signal, placed under mos-1272.
2026-10-22 10:41:40 -05:00
jason.woltje 6d9ab35039 row 269 (264b, #1774): BUILD.md names the sessioned-survivor residual in Q2 and records N1's mos-1292 allowance (duckworth-0159 N3, N4) 2026-10-22 10:15:32 -05:00
jason.woltje 2c16bc4209 row 269 (264b, #1774): BUILD.md Q2 notes that a survivor's released claim doesn't prove its run has exited, and the per-leftover 5 s wait (duckworth-0158 N1, N2) 2026-10-22 10:14:45 -05:00
jason.woltje 9221786228 row 269 (264b, #1774): BUILD.md answers sage-2750 on the recover() change; the survivor log names its pids
The section "recover() change, for a design read" covers three things, with
file:line against aca16404c. Every leftover is still bound and ended as
host-lost, and a survivor only refuses legacy-run-live while it lives. The
5 s wait applies only in the bwrap kill branch. No old test's expected value
changes. The fallback is to revert to the base recover() and leave the
refusal untested. The survivor log line now names the outer and runner pids
so an operator can clear it, and the new test's log assertion matches.
2026-10-22 10:14:11 -05:00
jason.woltje 3d29876dfb row 269 (264b, #1774): legacy-run-live gets its test (duckworth-0156)
recover() kept a survivor only when a bwrap outer pid outlived SIGKILL, so
the Q2 refusal (BUILD.md:177-179, t1-sessions.md:109) had no test. A leftover
whose runner still ran after its outer pid was gone was ended and dropped.
Now recover() waits on the outer pid and the runner, keeps any leftover where
either still runs, and logs that it still runs. check() reads only the
survivors that still run.

session-launch.test.mjs adds a test where a sessionless leftover's runner is
a sleep child and its outer pid is gone. It asserts the legacy-run-live
refusal with no launch and no session workspace, then one launch once the
child exits. BUILD.md adds mutants M10-M12 and replaces the "no test" note.
2026-10-22 10:11:49 -05:00
jason.woltje a81b3446eb row 269 (264b, #1774): refusedOnce in sessions.test.mjs pins actor_run, closing Duckworth N1 (DEFERRED.md:474-480)
Allowed by Mos in mos-1292 (copy mos-1294) on mos-1285's terms. The SELECT
reads actor_run, the expected row gets the run-name literal, and the three
callers pass `${name}-run`. Nothing else in the test changes. The old
expected row was:

  [['action.refused', { code: 'not-holder' }]]

DEFERRED.md gets a closing line on the entry naming row 269.
2026-10-22 10:09:37 -05:00
jason.woltje d595570a63 row 269 (264b, #1774): per-session store, workspace and single-writer lock
threadsState gives one row per session with `session` and `legacy`; threadIdle
idles a run against its bound session. Each session gets its own store
(threads/<b>/<t>/sessions/<s>/, or the thread's old store for a legacy-3l
session) and its own workspace (session-workspaces/<b>/<i>/<s>/), held by an
O_EXCL <ws>.lock from before the clones to the run's proven exit. A legacy
session's workspace binds in-sandbox at the old instance path. sessions.json
is v2 and reads v1. New tests: cli session-launch.test.mjs, seat
session-workspace.test.mjs. Not yet run: tests wait for Sage's CLEAR.

Two old assertions change as exact updates:
- mos-1285: end-launch.test.mjs threadsState deepEqual. The old row shape was
  { thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null,
  lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null }; the
  row keeps every key and value and adds session: 'th-1', legacy: true.
- mos-1289: thread-launch.test.mjs:182, old line
  `const store = join(f.dataRoot, "threads", "acme", A);`
  now reads A's default session id from threadsState's `session` key; :183-185
  unchanged; one added assert that threads/acme/<A>/session is absent.
2026-10-22 10:06:42 -05:00
jason.woltje e2e51bae1f row 269 (264b, #1774): BUILD.md lists thread-launch.test.mjs:182 as a second old assertion that changes. A thread.create thread's default session isn't legacy-3l, so its store takes D2's layout (t1-sessions.md:100, :294). Proposed as an exact path update, for Mos through Sage 2026-10-22 09:53:09 -05:00
jason.woltje f684317eb0 row 269 (264b, #1774): BUILD.md drops the planned bindLaunch session-running refusal. Two old tests bind a second unended run to one session and expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120), so the broker half of the fence is the existing per-session claim, thread-already-held (broker.mjs:4815) 2026-10-22 09:50:49 -05:00
jason.woltje 04676f57b9 row 269 (264b, #1774): design notes for Duckworth's early look (duckworth-0150). C1 adds the Console and R-guard sessions.json readers, the 264a refusals a v2 file causes, the /proc live check and the Console restart; C2 pins M6 to a generation change after check() with all eight start() admission calls named; C3 gives the realpath compare its own code and a symlinked workspaces root as M4's input; C4 lists the old threadsState assertions, end-launch.test:193 the only change, under mos-1285 2026-10-22 09:45:00 -05:00
jason.woltje 304bce2f13 row 269 (264b, #1774): design notes for sage-2717. Rollback names the four steps past the 264a exit-3 refusal and the re-pin plan section that carries them; new M8 binds a non-default session at the legacy path, old M8 becomes M9; Duckworth's legacy-bind check 2026-10-22 09:40:16 -05:00
jason.woltje 875b7eeebf row 269 (264b, #1774): design notes. D9 audit of every sessions.json reader at 84f53540d and of threadsState's consumers; sessions.json goes to v2 because recover() rebinds without session; pi 0.85.1 resumes by cwd, so the legacy session keeps its in-sandbox path; mutants M1-M8 and run limits (sage-2707) 2026-10-22 09:37:39 -05:00
32 changed files with 1396 additions and 1376 deletions

No files matched your search

+449
View File
@@ -0,0 +1,449 @@
# Row 269 build notes (Gizmoduck)
264b, T1 item 7: per-session native store, workspace and single-writer fence. Gitea #1774. Brief:
docs/plans/2026-10-21_t1-sessions.md (blob ff0be0c79), slices in agents/sage/work/queue-264/RULINGS.md
(5f5b51dd6). Reviewer Duckworth, gate owner Sage. Plan gizmoduck-0228, accepted with rulings in
sage-2707. Branch gizmoduck/row269 from refactor aca16404c. Row 253 lands first, and this branch is
rebased onto its tip, under a new name, before the gate.
Claim: rev 3217, op gizmoduck-0230, 2026-10-22 14:29:42Z. That was 21 s after Gander's slot opened
at 14:29:21Z (sage-2710). Reported in gizmoduck-0231 and not reverted, since a revert would be a
second queue write.
## D9 consumer audit, sessions.json (sage-2707 Q3)
Q3 allows `session` as an optional field with no version bump, if every sessions.json reader at
84f53540d selects by run and not by business, instance or thread. Every reader at 84f53540d:
| Reader | file:line at 84f53540d | Selects by |
| --- | --- | --- |
| `readSessions` (the only parser) | packages/seat/src/session.mjs:232-243 | nothing. Refuses any `sessionsVersion` but 1 (:241) and returns every entry with `live` |
| `stopSession` (`mosaic stop <run>`) | session.mjs:249-250 | run (`s.run === run`) |
| `launches list` | packages/cli/src/cli.mjs:526-530 | nothing. Prints every entry, and `--json` dumps them whole |
| host start, `leftover` | packages/cli/src/launcher.mjs:235 | business, then `recover()` works one entry (one run) at a time (:955-972) |
| runtime launch settings | packages/bus/src/runtime.mjs:149 -> packages/business/src/launch-settings.mjs:166 -> packages/business/src/launch.mjs:116-135 (`usageCounts`) | business. Counts entries per provider and model |
| Console settings (C1, duckworth-0150) | packages/webui/src/reads.mjs:234 -> the same launchSettings `read()` -> readSessions | business, through usageCounts. Any throw becomes `launch: {refused: 'not-configured'}` |
| stack-release R guard (C1) | scripts/stack-release.sh:2091-2096, its own JSON.parse, no version check | nothing. Takes `pid` and `runnerPid` of every entry in `sessions` and fails `sessions-live` if one is alive in /proc |
Not a reader: stack-release.sh:2199-2204 checks only that no sessions.json exists. packages/ledger
readSessions (ledger.mjs:146) is a different function that reads seat directories.
launch-settings `current()` (:129) doesn't call `sessions()`, so the limits the bus enforces don't
read the file; only the `limits` verb's `read()` (:166, runtime.mjs:292) does.
Tests that read the file: packages/cli/tests/launcher.test.mjs (`readSessions` at :164 through
:1097, each by run, by instance or whole) and packages/cli/tests/host.test.mjs:830 (file absent).
Not a file reader: `Registry` (session.mjs:192-230). Its list starts empty (:193), and the constructor
writes it over the file (:198). `running(business, instance, thread)` at :217 is the one key on the
thread, but it reads only the in-memory list this host added (launcher.mjs:529, removed at :553).
Its callers are launcher.mjs:289 (check) and the thread pass. This row changes that key (see Design).
Result: no file reader keys on the thread. Q3's condition holds as worded.
One more finding sends me to v2 anyway. `recover()` rebinds a leftover run with
`{business, role, run, harness, pid, startTime, thread}` and no `session` (launcher.mjs:964).
The broker compares that record's body with the recorded `session.launched` body, and the body
carries `session` whenever the launch record had one (broker.mjs:945). A mismatch refuses
`launch-record-mismatch` (broker.mjs:952). Each new thread run binds with its session (Design), so
after a rollback to 264a, a 264a host that finds a 264b entry logs "not ended, bindLaunch refused:
launch-record-mismatch" (launcher.mjs:968), and the run's claim stays held. The host has
already killed the process (:956-962), but it can't end the run. With v1 and an optional field,
that failure is quiet.
Decision: sessions.json goes to v2 (`SESSIONS_VERSION` 2). New code reads v1 and v2 and writes v2.
A v2 file keeps the `sessions` array and every v1 field of each entry, `pid` and `runnerPid`
included, so the R guard (stack-release.sh:2094-2095) reads it unchanged (C1).
A v1 entry has no `session`, and it is treated as the thread's legacy default session (D9). After a
rollback, a 264a host refuses to start with exit 3, "sessions file is malformed"
(session.mjs:241 via launcher.mjs:237), and the README's remedy applies: check which sessions still
run, then move the file aside. Tradeoff: a rollback while runs are live needs a hand step, but it
fails loudly at host start instead of leaving held claims. The rollback note names this refusal.
## D9 consumer audit, threadsState and threadIdle (sage-2707 audit addition)
These are trusted launcher APIs, not socket verbs (packages/bus/README.md:288, :294), and they're
reached only through the host op table (packages/bus/src/process.mjs:23-24, :54-55;
runtime.mjs:561-562). At aca16404c:
| Consumer | file:line | What it reads |
| --- | --- | --- |
| launcher thread pass | packages/cli/src/launcher.mjs:596, :611 | every row's `thread`, `holder`, `pending`, `unread`; threadIdle `{business, thread, run}` |
| bus end-launch.test | packages/bus/tests/end-launch.test.mjs:193, :198 | whole rows (deepEqual), and `not-holder` |
| bus thread-migration.test | packages/bus/tests/thread-migration.test.mjs:139 | `thread, holder, pending, unread` |
| bus threads.test | packages/bus/tests/threads.test.mjs:352, :375-376, :407, :416 | rows keyed by `thread` |
| cli launcher.test stub | packages/cli/tests/launcher.test.mjs:989 | wraps `host.op`, counts the op name |
| cli lifecycle.test stub | packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 | passes through, wraps `runtime.threadsState` |
| cli thread-launch.test stub | packages/cli/tests/thread-launch.test.mjs:90-91, :163, :264-265, :304, :348, :443 | `.find(x => x.thread === ...)` then `holder` |
Console and webui: no reader. `git grep threadsState` over packages/webui, packages/control-board and
scripts has no hit. The webui reads `thread.claims` (packages/webui/src/serve.mjs, cli.mjs,
public/bus.js, tests shell.test, thread-claims.test, thread-claims-browser.test). This row doesn't change
`thread.claims`. The webui's `session.launched` line reads only `harness` and `address`
(public/bus.js:151), so a `session` field in the body changes nothing it shows.
## D9 consumer audit, workspace and native store
| Consumer | file:line at aca16404c | Change in this row |
| --- | --- | --- |
| `workspaceDir` | packages/seat/src/session.mjs:49 | unchanged, still the non-thread path and the legacy path |
| launcher start, workspace | packages/cli/src/launcher.mjs:397-403 | a thread run uses its session workspace (Q1) |
| launcher instance delete (V5) | launcher.mjs:841-866 | also retires the instance's session workspaces |
| `threadStore` | session.mjs:59-74, launcher.mjs:388-393 | becomes the legacy branch of `sessionStore` |
| sandbox binds | packages/seat/src/sandbox.mjs:102-125 | the workspace gets a separate in-sandbox path |
| harness `transcript()` | packages/plugins/harness/pi/index.mjs:75, claude-code/index.mjs:130 | none: `{session, projects}` keeps its shape |
| in-sandbox readers of `session.workspace` | packages/harness/src/runner.mjs:332, :386, :475-476; bundle.mjs:258, :327; gate.mjs:152 | none: they get the in-sandbox path |
| task runner workspaces | scripts/mosaic-task.mjs:351 | none: `<dataRoot>/workspaces/<task>` is a legacy root the overlap check covers |
| git socket | packages/cli/src/git-socket.mjs:7 | none: it takes bundles and reads no workspace |
## Pi and Claude resume, read offline (sage-2707: no pi run while P19 383 holds)
Pinned `@earendil-works/pi-coding-agent` 0.85.1 (package.json:8), read in node_modules/.../dist:
- adapters/pi/adapter.sh:46-52 passes `--session-dir "$MOSAIC_SESSION_DIR"`, plus `-c` when that
directory isn't empty.
- With `-c` and an explicit session dir, `continueRecent` filters by cwd
(core/session-manager.js:1244-1250). It opens the newest `*.jsonl` whose header cwd resolves to
the process cwd (:397-410, `sessionCwdMatches` :393-395). With no match, it makes a new session in
the same directory (:1251), and nothing fails.
- Both adapters `cd "$MOSAIC_WORKSPACE"` (pi adapter.sh:34-36, claude adapter.sh:46-47), the runner
sets `MOSAIC_WORKSPACE` from `session.workspace` (runner.mjs:386), and the sandbox binds the
workspace at its host path (sandbox.mjs:112, `argsOf` :128-131). So a legacy transcript's header
cwd is `<dataRoot>/workspaces/<business>/<instance>`.
- Opening a file whose header cwd doesn't exist exits 1 in a headless run (main.js:536-548,
core/session-cwd.js:2-15). So `--session <file>` is no way around it either.
Result: a legacy default session run from `session-workspaces/.../<session>` as its cwd would
quietly start a new pi conversation, and its old history wouldn't resume. Claude Code keys its
projects directory on the cwd, and the adapter resumes by id (claude adapter.sh:53-59). I haven't
read the Claude binary, so whether `--resume` finds an id saved under another cwd is unverified. I
treat it as the same break.
## Design
### Legacy mapping (D9, M7)
A session is legacy when its row has `op='legacy-3l'`, which the migration gave every pre-3l thread
with `id` = the thread id (3k-3l.sql:50-51). The rule reads only an immutable row, never the disk.
- A legacy session keeps the thread's existing store, `threads/<b>/<t>/{session,claude-projects}`
(`threadStore`, unchanged).
- Its workspace is new (Q1), `session-workspaces/<b>/<instance>/<session>/`, but inside the sandbox
it's bound at the legacy path `workspaces/<b>/<instance>`. The cwd its transcripts recorded is
unchanged, so pi `-c` and Claude `--resume` find them. The host's old instance workspace isn't
mounted, so it stays as history (Q1, D9). Every in-sandbox reader gets the sandbox path (table
above), and host git (`ensureCheckouts`) gets the host path.
- Every other session uses D2's layout for both, and its sandbox path is its host path.
- A thread made by 264a's `thread.create` has a random default id (broker.mjs:5993), so this rule
doesn't call it legacy. A 264a launcher ran that thread from `threads/<b>/<t>/session`. A start
for a session whose thread has no legacy-3l session, while `threads/<b>/<t>/session` exists,
refuses `legacy-store-unmapped`, so it fails closed and doesn't start a fresh conversation. The
re-pin plan lists such threads. If 264a and 264b go live together, there are none.
### Broker
- `threadsState(business)`: one row per session of every thread, not just the default, ordered by
thread seq, then session seq. Each row adds `session` and `legacy` to the fields it has now.
`#threadRows` joins `sessions` in place of `session_defaults` (broker.mjs:868-896).
- `threadIdle(record)`: same keys. The row is the one for the session the run was bound to
(`#runs` entry, broker.mjs:927), so a run is idled against its own session.
- `bindLaunch`: no new refusal. It already takes `session` and puts it in the body (broker.mjs:945).
The broker half of the single-writer fence is the per-session claim that exists now:
a second claim on a held session refuses `thread-already-held` (broker.mjs:4815). A
`session-running` refusal at bind would break two old tests that bind a second unended run to one
session and then expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120).
The launcher's registry check and the workspace lock are the host halves.
### Seat
- `sessionStore(dataRoot, business, thread, session, { legacy })`: the legacy branch is
`threadStore`. Otherwise it's `threads/<b>/<t>/sessions/<s>/{session,claude-projects}`, with
`threadStore`'s checks on every segment and directory (ID_PATTERN, no `..`, owner, 0700, no
symlink), giving the same `{dir, session, projects}` shape.
- `sessionWorkspace(dataRoot, business, instance, session, { live })`: makes
`session-workspaces/<b>/<i>/<s>` 0700 with the same checks, then compares realpaths. It refuses
`session-workspace-overlap` when the path is inside, or contains, `<dataRoot>/workspaces` (legacy,
`.retired` and task workspaces) or any workspace in `live`. A segment that fails the lstat checks
refuses `session-workspace-unsafe` first (C3).
- Lock: `lockSessionWorkspace(path, owner)` makes `<path>.lock` with O_EXCL, next to the workspace
and outside every mount. It holds the host's pid and start time, and the run's outer pid and
start time once spawned. A lock whose host and run are both gone is stale and is taken over.
Anything else refuses `session-workspace-busy`. start() takes it before `ensureCheckouts` and
releases it only after the run's `close` (proven exit) or a failed start.
- `Registry`: entries gain `session` and `workspace`, and the key becomes `running(business,
instance, thread, session)`. `SESSIONS_VERSION` is 2, and `readSessions` reads 1 and 2.
### Launcher
- check(instance, { thread, session }): `instance-running` is per session. Q2 adds
`legacy-run-live`: a start for a thread instance refuses while any registry entry or unrecovered
leftover for that instance has no `session`.
A survivor is still bound and ended host-lost (launcher.mjs:1030-1040), so the broker sees its
claim free while it runs. A released claim doesn't prove the run has exited. Only this launcher
check holds the instance then, for sessionless survivors and thread starts only (duckworth-0158
N1). recover() waits up to 5 s per leftover for the outer pid and the runner (N2, see "recover()
change, for a design read").
Residual (duckworth-0159 N3): a survivor with a `session` counts nowhere, because :309 counts
only sessionless entries. The same session could start again while the old runner lives. The
lock doesn't catch it either: it records the outer pid as runPid (launcher.mjs:574), so with the
outer pid gone it reads stale and is taken over. The base has the same exposure. In practice
the runner dies with its pid namespace when the outer bwrap is killed.
- start(): the store and workspace come from the session. The lock covers checkouts and spawn.
`bindLaunch` gets `session`, and the entry records it.
- threadPass(): one pass per session row. `startedFor` and `refusedThreads` key on the session.
The `thread-idle`, `refused` and `end` records add `session`.
- recover(): passes the entry's `session` when present.
- remove(): moves `session-workspaces/<b>/<instance>` to `session-workspaces/<b>/.retired/<instance>-<transaction>`
after the legacy move, and records `workspace-retired` with that path.
- Admission (RULINGS Q5, D8): a named session starts only through threadPass, then check(), then
start(), so it passes through every `admission()` call start() makes now (launcher.mjs:382-524).
There is no second start path.
### Sandbox
`binds()` takes `workspaceDest` (default `workspace`), the in-sandbox path for the rw workspace
bind. The store binds keep their shape: only the selected session's two directories.
## Tests
T1 run half, with the fake harness adapter from thread-launch.test (no pi or Claude binary):
two sessions of one thread run at once, each with its own sentinel history, store and workspace;
stopping one and restarting it resumes only that one. The legacy session resumes from the old
store at the old in-sandbox cwd. A non-thread instance gets `thread-not-allowed`.
T2: a second start on a live session's workspace refuses `session-workspace-busy` before any
checkout; a hand-made live lock from another host refuses; a symlinked or nested session root
refuses `session-workspace-unsafe`; a workspace root symlinked into the session path refuses
`session-workspace-overlap`; a store segment symlink refuses `thread-store-refused`. Under a halt, a
named session's start refuses `processing-halted` and spawns nothing. A generation change after
check() refuses `processing-generation-changed` and spawns nothing (M6).
N1 (DEFERRED.md:474-480): `refusedOnce` in packages/bus/tests/sessions.test.mjs pins `actor_run`.
## Old assertions that change (C4, duckworth-0150, mos-1285)
Every pre-existing test that reads threadsState at aca16404c, checked against one row per session:
- packages/bus/tests/end-launch.test.mjs:193-195 deepEquals the whole row. This one changes. Mos
allowed it in mos-1285 as an exact update: still a whole deepEqual on threadsState('demo'), still
exactly one th-1 row, every old key and value the same, and two new keys with literal values,
`session: 'th-1'` (the fixture's legacy default, :14-15) and `legacy: true` (op `legacy-3l`). The
commit message names mos-1285 and the old row shape.
- packages/bus/tests/threads.test.mjs:352, :375-376: fromEntries keyed by thread. Neither test, and
neither fixture (`twoThreads`), makes a second session (no `session.create` in the file), so each
thread still has one row. Unchanged.
- packages/bus/tests/thread-migration.test.mjs:139: maps four fields of a one-session thread.
Unchanged.
- packages/cli/tests/thread-launch.test.mjs:163, :304, :348, :443: `find` by thread. One session
per thread in every fixture. Unchanged. The default session also sorts first, by session seq.
- packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 and thread-launch.test.mjs:90-91, :264-265
pass threadsState through. Unchanged.
- packages/cli/tests/thread-launch.test.mjs:182 (found in the code round, after mos-1285). The test
makes threads with `thread.create` (:171-172), so A's default session has a random id and op
`default` (broker.mjs:5992-6009), not `legacy-3l`. The brief puts every new store in D2's layout
(t1-sessions.md:100, :294), so A's store moves to `threads/acme/<A>/sessions/<A's session>`. The
proposed exact update changes only the `store` path on :182, taking the session id from
`threadsState` (the test already reads it at :162). Lines :183-185 stay as they are. One added
line asserts that `threads/acme/<A>/session` doesn't exist. This goes to Mos through Sage, and no
code commit edits the file until Mos rules.
- The same file's :375 (`../evil` refuses `thread-store-refused`) and :381, :384 (no `threads`, no
`workspaces`) stay unchanged: start() checks the store before it makes the session workspace.
Any other old assertion the code changes goes to Mos through Sage before the commit. After the
first run I survey every touched file whole for one.
## Mutants
Each kill counts only as an assertion failure in a test that runs that path, never a throw or
another fence (sage-2707, C3 duckworth-0128).
| Id | Change | Killed by |
| --- | --- | --- |
| M1 | `sessionWorkspace` drops the session segment: a thread's sessions share one checkout | T2 same-thread fence: `session-workspace-busy`, no second spawn |
| M2 | the session segment becomes the session name: two threads' `default` sessions share one checkout | T2 cross-thread fence, same assertion |
| M3 | the lock ignores a live holder | T2 hand-made lock: `session-workspace-busy` |
| M4 | the realpath overlap check removed | T2 nesting: `session-workspace-unsafe` |
| M5 | the launcher passes the thread's default store for every session | T1: the second session's sentinel history is its own |
| M6 | start() calls a no-op in place of every `admission(admissionGeneration)` (launcher.mjs:382, :396, :407, :420, :495, :499, :520, :524) for a non-legacy session | the generation test (below): `assert.rejects` with code `processing-generation-changed`, then `assert.equal` spawn count 0 |
| M7 | the legacy session maps to `sessions/<s>/` | the D9 test: the legacy run resumes the sentinel in `threads/<b>/<t>/session` |
| M8 | a non-default session binds its workspace at the legacy `workspaces/<b>/<instance>` in-sandbox path | T1: `assert.equal` that a named session's in-sandbox workspace is its `session-workspaces/<b>/<instance>/<s>` path, read from the fake adapter's recorded cwd (sage-2717) |
| M9 | `#threadRows` back to the default session only | T1: the named session's message starts its run |
| M10 | the `legacy-run-live` refusal removed from check() | the legacy-run-live test: `outcome` equals `[[S, "legacy-run-live"]]` |
| M11 | recover() keeps a survivor only when the outer pid outlived the kill (the runner ignored) | the legacy-run-live test, same assertion |
| M12 | check() reads every survivor, live or not | the legacy-run-live test: after the runner exits, `settle` gives one launch |
M6 and the halt fences (C2, duckworth-0150). A plain halt never reaches start(). tick() admits first
(:579) and runs no thread pass while halted (:583), and check() admits at :282 and :376. The kill
test lands the change between check() returning and the spawn:
- The fake host's `credentialStatus` op (start() calls it at :395, after check() returned and after
:382) records that it ran, then halts and resumes processing through `transition`. That leaves
`halted` false and a new generation (state.mjs:115-121).
- The tick and check fences see no halt, and start()'s second check() (:419) takes its own
generation, so it passes too. withProcessing at :406 has no parent context (the launcher never
opens one) and checks only `halted`, so it passes. The one fence left is start()'s own
`admission(admissionGeneration)`, which refuses `processing-generation-changed` at :396.
- The test asserts the hook ran (check passed), then the refusal code, then no spawn.
M6 removes all eight calls because removing only :382 leaves :396 to refuse. The T2 halt test stays
as written in Tests, but it doesn't kill M6 and isn't counted for it.
M4 and the segment checks (C3, duckworth-0150). The lstat check on each segment refuses a symlinked
session root before the realpath compare runs, so the compare gets its own code,
`session-workspace-overlap`, and the segment check keeps `session-workspace-unsafe`. M4's kill input
passes every segment check: `<dataRoot>/workspaces/<b>` is a symlink to
`session-workspaces/<b>/<i>/<s>`. Each segment of the session path is a real directory, and only the
realpath compare finds that the session path contains the legacy workspace root. The test asserts
`assert.rejects` with code `session-workspace-overlap` and no spawn. With M4 the start spawns, so
the rejection assertion fails. The segment test keeps asserting `session-workspace-unsafe` for a
symlinked session root, so it tells the two codes apart.
Duckworth's check for the early look (sage-2717): a non-default session never gets the legacy
`workspaces/<b>/<instance>` bind path. Only a session with op `legacy-3l` takes `workspaceDest`
set to the legacy path. M8 is that mutant.
### Code round corrections (local, not yet run)
Written before the first run, which waits for Sage's CLEAR.
- Tests live in two new files: packages/cli/tests/session-launch.test.mjs (T1, D9, T2) with a new
stub adapter, fixtures/session-adapter.mjs (thread-adapter.mjs's turn plus the run's cwd and its
entries; thread-adapter.mjs is unchanged), and packages/seat/tests/session-workspace.test.mjs
(sessionStore, sessionWorkspace, the lock, sessions.json v2). The new launch tests copy
thread-launch.test.mjs's host helpers rather than change them, so no other old test file changes.
- M1 and M2 kill in T1, not in a busy test. A test that expects `session-workspace-busy` still
passes when sessions share a path. T1 starts thread A's default and named sessions and thread B's
default at once and asserts no refusal, three live entries and three distinct cwds. With M1 (A's
two sessions) or M2 (the two `default` sessions) the second start refuses busy, and the
`deepEqual(refused, [])` assertion fails.
- Every wait a mutant could stall returns what it last saw (`settle`, `outcome`), so a kill is the
assertion after it, never a timeout. That covers M3, M4, M6 and M9, where the mutant's run starts
or never starts.
- The M6 test also carries the halt fence. start() admits right after credentialStatus, before it
makes the session workspace, so the first refusal leaves no workspace. On the retry the recheck's
mayLaunch op halts after the lock is taken. The recheck refuses `processing-halted`, and the test
asserts that the lock is gone (startLocked's throw releases it).
- The lock's release() runs once. A second call would unlink a lock another host has written since.
- `legacy-run-live` now has a test (Duckworth's early warning in duckworth-0156). Before, recover()
kept a survivor only when a bwrap pid outlived SIGKILL, which a test can't make. It also ended
a leftover whose runner still ran once the outer pid was gone, so the refusal never saw that run.
Now recover() waits up to 5 s on the outer pid and the runner. It keeps any leftover where
either still runs, and logs `run <run> (<instance>) from an earlier host still runs`. check()
reads only survivors that still run, so a start goes ahead once the run has exited. The test
writes a sessionless leftover whose runner is a sleep child and whose outer pid is gone. It
asserts the refusal, no launch, no session workspace, and then one launch after the child is
killed. M10-M12 cover it. The registry and pre-recover leftover terms stay. A launching host
can't reach them for a thread instance (check() refuses `session-required` first, and listen()
runs recover() first), but they cost nothing.
### recover() change, for a design read (sage-2750)
Sage asked for this because recover() predates the row. Lines are launcher.mjs at 3d29876df plus
the log-line edit after it, against aca16404c:947-976.
- What stays the same: every leftover is still bound and ended as host-lost (:1030-1040, base
:963-973). The survivor push (:1026-1029) comes before the bind, and the loop carries on past it,
so a kept leftover isn't left unended. Its bus run ends and its role is released, as in the base.
The kill is unchanged too: SIGKILL only to a live outer pid whose cmdline is bwrap (:1018-1021,
base :956-959).
- What changes:
1. The wait after the kill runs while the outer pid or the runner still runs (:1023). In the
base it waited on the outer pid only (base :961).
2. A leftover where either still runs after that goes on `survivors` and is logged (:1026-1029).
3. check() counts a survivor only while it still runs (:309, `liveRun` at :255).
- a. Nothing in this row kills a kept leftover's runner. The base didn't either: it ended the run
and let the runner go on, unseen. The run is still ended (:1038), so the runner holds no role.
Its capability died with the old broker (:1004-1008). The only thing a survivor does is refuse
`legacy-run-live` for sessions of its instance while it lives, and each refusal is a `refused`
launch record. For an operator, the log line names the run, the outer pid and the runner pid
(:1028). Killing that pid clears it. check() re-reads liveness on each pass (:309), so the next
tick starts the session with no host restart. If bindLaunch refuses (:1032-1036), the run stays
unended and logged, as in the base (base :965-969). The survivor entry doesn't change that.
- b. The 5 s wait only applies inside the kill branch (:1018-1024), so only to a leftover whose
outer bwrap was still alive. A leftover with nothing running doesn't wait, and nor does one
whose outer pid is gone: neither enters the branch. The wait ends as soon as both processes are
gone. The base already waited up to 5 s in the same branch. It can now last longer only if the
runner outlives its outer pid. With `--unshare-pid` and `--die-with-parent` the runner dies when
the namespace goes.
- c. Old tests that run recover() with a launching host and a leftover:
- launcher.test.mjs:885-943, the "host that died hard" pair. It asserts the outer pid and the
runner are gone right after setup (:917-918), the host-lost log (:919), one session.ended
(:920-922), one end record (:924), an empty sessions.json (:925), and then "already ended" for
a leftover whose processes are dead (:937-941). None of these expected values change.
Waiting on the runner makes :918 hold sooner, not later. The new log line can appear only if
a process outlives 5 s, which would already fail :917-918. The dead-process third host takes
no wait and logs no survivor.
- launcher.test.mjs:950-971 (`--no-launch`) returns before the loop (:1012-1015). It's
unchanged.
- stack-release.test.mjs:1928 writes `{pid: process.pid}` with no start times. `liveRun` is
false for it, and that test reads it through bus status, not recover().
- verbs.test.mjs:213 and the seat tests build a Registry with no launcher, so they aren't
affected.
No expected value in an old test changes, so nothing goes to Mos under mos-1272 for this. If a
run shows otherwise, I stop and send it before any change.
- Fallback, if Duckworth or Sage doesn't accept this: revert recover() and check() to the base
behaviour, keeping only the sessionless check over the registry, the pre-recover leftovers and
bwrap survivors. Drop the legacy-run-live test and M10-M12, and name the untestable path here.
- N1 (refusedOnce pins `actor_run`) changes a pre-existing test. Mos allowed it in mos-1292 (copy
mos-1294) on mos-1285's terms, and it's committed as a81b3446e, with a closing line on the
DEFERRED.md entry.
## Rollback
264b writes sessions.json v2. A 264a host given that file refuses to start, exit 3, "sessions file
is malformed" (session.mjs:241 via launcher.mjs:235-237). Session workspaces and per-session stores
stay on disk, and a 264a host never reads them.
What else refuses on a 264a stack while a v2 file is in place (C1, duckworth-0150). All of them go
through the 264a readSessions, which throws SeatError "sessions file is malformed" (session.mjs:241):
- `mosaic launches list` and `mosaic stop <run>`: exit 2 with that message (cli.mjs:526, :465).
- the bus `limits` verb: `settings.read()` throws (runtime.mjs:149, :292, launch-settings.mjs:166),
so the verb fails with that message. The limits the bus enforces still work, since `current()`
doesn't read the file. I haven't traced the code a bus caller sees for that throw.
- the Console settings view: launch shows `not-configured` (reads.mjs:234).
- the R guard keeps working (above).
All of these read the file on each call, so step 3 below clears them without a restart.
Console restart: the shape of what the Console reads doesn't change. usageCounts still counts
entries per provider and model, and reads.mjs keeps its fields. What changes is the file, and a
Console still on 264a code shows launch `not-configured` from the first v2 write until a human
restarts it on a 264b copy. The re-pin that carries this row needs its Console restart (Jason or
Mos), done right after the host's, as it does for a read-shape change. Step 3 covers the reverse
case after a rollback with no restart.
Seeing live sessions without `launches list` (C1): read the file directly the way the R guard does.
For each entry in `sessions`, a session is live if `/proc/<pid>/stat` or `/proc/<runnerPid>/stat`
exists and its state isn't `Z` (stack-release.sh:2080, :2095). For a stronger check, compare
`runnerStartTime` with field 22 of `/proc/<runnerPid>/stat`, which is what readSessions' `live`
does through startTimeOf.
How a rollback host gets past exit 3 (sage-2717), run on NEW before the rollback activate:
1. Stop the bus host on NEW. Its stop SIGKILLs what is left and ends those launches as `killed`
(packages/cli/README.md:470).
2. Read `sessions.json` with NEW's readSessions (`mosaic launches list`, cli.mjs:526-530). Go on only
if it shows no live entry: every listed runner is gone (pid and start time no longer match). If
NEW's CLI is already gone, use the /proc read above.
3. Move `sessions.json` aside under a dated name, never delete it (packages/cli/README.md:478).
4. Then roll back. The 264a host starts with no leftover file and nothing to recover.
A live entry at step 2 stops the rollback until that run ends or `mosaic stop <run>` ends it.
Which re-pin plan step carries it: no re-pin plan names 264b yet. The 84f53540d re-pin carries 264a
only. The first re-pin whose NEW includes this row puts steps 1-4 in its "Stop conditions and
rollback" section, before `rollback --plan`, the section the 10-24 plan uses
(agents/sage/work/window-plan/repin-1024-draft1.md:144-148). I'll name it to whoever drafts that
plan when the row lands.
## Run limits (sage-2707)
Each node --test file and suite runs under `timeout -k 10 LIMIT`, and each scripts/test-*.sh under at
least 3x its longest recorded run. Records are from agents/gizmoduck/work/queue-264/BUILD.md "Run
limits" unless named.
| Run | Longest recorded | LIMIT |
| --- | --- | --- |
| stack-release.test alone | 575 s (264a r12 survey) | 1800 s |
| each other touched file alone | 15 s (thread-launch.test); the rest have none | 300 s |
| bus suite | 23 s | 900 s |
| cli suite | 591 s (row 281 gate) | 1800 s |
| webui suite | 57 s | 600 s |
| seat suite | none recorded | 600 s |
| harness suite | none recorded | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant probe | 1 s wall (264a M1-M4) | 300 s |
-132
View File
@@ -1,132 +0,0 @@
# Row 206 build notes (Launchpad): 193b2, thread shares (#1704)
Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)"
(blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…),
sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
## Base and chain
- Started on aca16404c (sage-2789) on branch launchpad/row206.
- 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase
onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name.
- At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer
sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818
Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1,
duckworth-0166). On the 275
rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs
must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers).
- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under
Q173, with a backup first and a STATE report after (mos-1310 item 4).
## What changed
- Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in
schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256
d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m.
`tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…).
- `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in
scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in
the counted list, not the untimed one.
- broker.mjs:
- `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and
target. `#covered` reads the access document once per call and covers nothing on a read
failure, as `#userGrants` does.
- `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only
`thread.messages`. `#thread` keeps the owner check for every write verb.
- `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}`
adds `owner`; the plain list keeps its shape (5.5).
- `replies` leaves out user thread messages except those in threads `#readable` admits.
`trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`.
- USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list.
- packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the
new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first.
## Choices and departures (for Sage and Duckworth)
1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by
landing order (sage-2789).
2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818
Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its
"no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test
holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`).
**Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's
`session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5,
docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread
owner's included.
3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human
principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in
other users' unshared threads hidden" for human, reader and tester. The reader sees what the
owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an
agent session has no owner, but it matched a `*` share, so a shared user message showed in an
agent's trail. `#readable` now returns false unless the session is the business human, a
reader or a user. A test binds an assistant run to the shared thread and a chief run with no
thread, and checks that neither (nor the tester) sees the message after a `*` share and a share
to the owner.
4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have
no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on
every request. The tests cover the per-request read (removed, expired, out of scope and
unreadable), which is the stricter case.
5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant
covering t"). A user owner lowered to viewer keeps the thread readable to its sharees.
6. An unshare checks no coverage, so an owner can end a share whose target lost its grant.
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
note; at this base every user is a collaborator, so the two agree until 275.
## Tests
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks,
lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and
replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no
share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store.
Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and
thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS).
The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql;
comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration
also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the
table, since each compares a store migrated through 3m against an older one.
## Mutants (design section 8 numbers)
Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named
test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count.
- M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return
false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to
p2) and by the `*` test (carol reads).
- M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after
alice's grant is removed).
- M4. In `#shared`, replace the latest-row lookup with any share row:
`SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by
"mutant 4" (bob still reads after the unshare).
- M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`.
Killed by the first test (bob's send to A must refuse `thread-not-owned`).
- M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share
to user:carol must refuse).
- M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with
`this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse).
- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line
(`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal
matches a share" (the agent's trail shows the message after the `*` share).
## Run limits
From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the
longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell
phase goes to Mos unplaced (mos-1275).
| Run | LIMIT |
|---|---|
| stack-release.test alone | 1800 s |
| each other touched file alone | 300 s |
| bus suite | 900 s |
| cli suite | 1800 s |
| each scripts/test-*.sh | 900 s |
| each mutant probe | 300 s |
## Runs
None yet. Node and https git wait for Sage's CLEAR (Gander holds the slot for row 253 run 5).
+2
View File
@@ -478,6 +478,8 @@ at every gate. Started 2026-09-12 during the control board MVP.
not that the event's `actor_run` is the refused run. Binds row 269 (264b,
#1774), or the next row that changes `sessions.test.mjs`: pin `actor_run`
in `refusedOnce`. (2026-10-22)
Closed by row 269 (264b, #1774): `refusedOnce` selects `actor_run` and
expects the refused run's name (mos-1292). (2026-10-22)
- **editor-provenance skips its rebuild check on esbuild 0.28.1.** (Duckworth
N2 on the row 264a manifest round, duckworth-0146.) The webui test
+7 -33
View File
@@ -240,12 +240,10 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
@@ -285,31 +283,7 @@ any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread, unless it is shared with them (Thread shares, below).
### Thread shares (row 206, #1704)
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
row per thread and target is the share's state. A thread's owner shares it read-only with a
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
so every existing thread stays private to its owner.
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
1. a share is in force to the caller's owner string or to `*`;
2. a user caller has a live grant, of any level, covering the thread's project and instance;
3. a thread owned by a user is still covered by that user's grant;
4. neither the caller nor the owner is the tester.
Only a human principal matches a share: the business human, a reader session as that human, or
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
another user's thread.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
@@ -792,10 +766,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3m, SHA-256
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
`schema.sql` is v3l, SHA-256
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
-15
View File
@@ -478,18 +478,3 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
-- (thread, target) is the share's state. Append-only.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+29 -126
View File
@@ -131,9 +131,7 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
// Row 206 (193b2, row 193 design 4.3): and the share verbs on a user's own threads.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
'thread.share', 'thread.unshare', 'thread.shares']);
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
const USER_LEVEL = 'collaborator';
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
@@ -468,7 +466,6 @@ export class Broker {
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
]) {
const at = store.get(`SELECT max(at) at FROM ${table}`).at;
if (at) {
@@ -514,7 +511,7 @@ export class Broker {
if (s.human || s.reader || s.tester) return;
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
'thread.list', 'thread.messages', 'thread.shares', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
}
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
@@ -756,22 +753,12 @@ export class Broker {
return thread;
}
#thread(s, id) {
const t = this.#threadRow(s, id);
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
// REQ-USER-2: a user reaches only their own threads.
if (t.owner !== this.#owner(s)) fail('thread-not-owned');
return t;
}
#threadRow(s, id) {
return this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business) ?? fail('thread-not-found');
}
// Row 206 (193b2, design 5.4): the thread a read verb names, the caller's own or one #readable
// admits. Only thread.messages takes it; every write verb keeps #thread's owner check.
#threadRead(s, id) {
const t = this.#threadRow(s, id);
if (t.owner === this.#owner(s)) return this.#grantedThread(s, t);
if (!this.#readable(s, t)) fail('thread-not-owned');
return t;
}
// Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's.
#owner(s) {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
@@ -815,62 +802,10 @@ export class Broker {
);
return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t);
}
// Row 206 (193b2, design 5.4): may a session read thread `t` it doesn't own? Rule 4 first: a
// tester's thread is never shared, and a tester is never a share's target and holds no grant. A
// reader session is the owner's board view, so it reads what is shared with the owner. Only a
// human principal (the business human, a reader as that human, a user) matches a share: an agent
// session, even one bound to the thread, matches none, `*` included (sage-2818 Q2).
// Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before
// 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4.
#readable(s, t) {
if (s.tester || t.owner === 'tester') return false;
if (!s.human && !s.reader && !s.user) return false;
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
// Rule 1: a share in force to the caller or to everyone.
if (!this.#shared(t.id, me) && !this.#shared(t.id, '*')) return false;
// Rule 2: a share never widens a grant. The owner needs none.
if (s.user && !this.#covered(s.user, s.business, t)) return false;
// Rule 3: a thread whose user owner has lost coverage is hidden from its sharees too.
if (t.owner.startsWith('user:') && !this.#covered(t.owner.slice(5), s.business, t)) return false;
return true;
}
// A share's state is its latest row for the thread and target (design 5.2, Duckworth N12).
#shared(thread, target) {
return this.#store.get('SELECT op FROM thread_shares WHERE thread=? AND target=? ORDER BY seq DESC LIMIT 1', thread, target)?.op === 'share';
}
// Does a live grant of any level cover thread `t` for `user`? One read of the access file, as
// #userGrants does; any failure to read covers nothing (design 5.6, H3).
#covered(user, business, t) {
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
}
// The threads another principal owns that the session may read, oldest first.
#readableThreads(s, project) {
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
return this.#store.all(
"SELECT t.* FROM threads t WHERE t.business=? AND t.owner IS NOT ? AND (? IS NULL OR t.project=?) AND EXISTS(SELECT 1 FROM thread_shares x WHERE x.thread=t.id AND x.target IN (?,'*')) ORDER BY t.seq",
s.business,
this.#owner(s) || null,
project ?? null,
project ?? null,
me || null,
).filter((t) => this.#readable(s, t));
}
// Row 206 (design 5.3): a share's target, an owner string: `user:<id>`, the business human's
// name or `*`. A share with yourself is invalid.
#shareTarget(s, to) {
const b = this.#business(s.business);
if (typeof to !== 'string') fail('invalid-request');
const user = to.startsWith('user:') ? to.slice(5) : null;
if (!(to === '*' || to === b.human || (user !== null && ID_PATTERN.test(user) && !['human', 'tester', 'user', b.human].includes(user))))
fail('invalid-request');
if (to === this.#owner(s)) fail('invalid-request');
return to;
return false;
}
#agent(s) {
if (s.tester) fail('tester-refused');
@@ -902,7 +837,7 @@ export class Broker {
return cap;
}
// Row 121 (#1615): what the launcher needs to start, stop and recover thread sessions.
// Trusted launcher API, not a socket verb. Per thread: messages to the instance that no
// Trusted launcher API, not a socket verb. Per session of each thread (row 269, 264b): messages to the instance that no
// run has been delivered yet, messages delivered to the current holder and not yet read
// (a turn in progress), and the latest message, open and claim times. A message delivered
// to an earlier run and never read (it crashed mid-turn) isn't the holder's turn, so it
@@ -922,30 +857,31 @@ export class Broker {
const session = this.#runs.get(key) ?? fail('unknown-run');
if (this.#ended.has(key)) fail('run-ended');
// A run holds at most one session, so this also refuses another thread and a non-thread run.
// Row 264a: the row is the thread's default session's, so a run of another session is refused too.
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
// Row 269 (264b): the row is the session the run was bound to, so a run is idled against its own.
const [t] = this.#threadRows('t.business=? AND t.id=? AND se.id=?', record.business, identifier(record.thread), session.session);
if (t?.holder !== record.run) fail('not-holder');
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
return { released: true };
}
// Row 264a (#1769): each row is the thread's default session's. Until 264b the launcher starts
// default sessions only, so another session's messages wait.
// Row 269 (264b, #1774): one row per session of each thread, oldest thread first, then oldest
// session. `legacy` is a session the 3k-3l migration made (op legacy-3l), whose store and
// in-sandbox workspace path are the thread's old ones.
#threadRows(where, ...params) {
return this.#store
.all(
`SELECT t.id, t.instance,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
`SELECT t.id, t.instance, se.id AS session, se.op AS session_op,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session) AS last_message,
(SELECT max(at) FROM thread_opens WHERE session=ds.session) AS last_open,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id) AS last_message,
(SELECT max(at) FROM thread_opens WHERE session=se.id) AS last_open,
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
FROM threads t JOIN session_defaults ds ON ds.seq=(SELECT max(seq) FROM session_defaults WHERE thread=t.id)
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=ds.session)
WHERE ${where} ORDER BY t.seq`,
FROM threads t JOIN sessions se ON se.thread=t.id
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id)
WHERE ${where} ORDER BY t.seq, se.seq`,
...params,
)
.map((r) => ({
@@ -957,6 +893,8 @@ export class Broker {
lastOpen: r.last_open,
lastClaim: r.claim_at,
holder: r.claim_op === 'claim' ? r.holder_run : null,
session: r.session,
legacy: r.session_op === 'legacy-3l',
}));
}
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
@@ -5814,13 +5752,8 @@ export class Broker {
// and the tester's lists only its own (row 175 listed it the business human's).
case 'thread.list': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'shared']);
keys(a, ['project']);
if (a.project !== undefined) identifier(a.project);
if (a.shared !== undefined && a.shared !== true) fail('invalid-request');
// Row 206 (193b2, design 4.3): another's threads shared with the caller, each naming its
// owner. The plain list keeps its shape.
if (a.shared)
return this.#readableThreads(s, a.project).map((t) => ({ id: t.id, at: t.at, project: t.project, instance: t.instance, name: t.name, owner: t.owner }));
// FED R3 (design 3.5): a user's own threads that its grants still cover.
return this.#store.all(
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
@@ -5871,12 +5804,11 @@ export class Broker {
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing. Row 206 (193b2): a thread shared
// with the caller reads too, every session of it, since a share is the whole thread's.
// The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#threadRead(s, a.thread);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
@@ -5895,34 +5827,6 @@ export class Broker {
after,
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
// of any level that covers it. Each writes one row; the latest row per target is the state.
case 'thread.share':
case 'thread.unshare': {
if (!s.user) this.#human(s);
keys(a, ['thread', 'to'], ['thread', 'to']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const to = this.#shareTarget(s, a.to);
const op = verb === 'thread.share' ? 'share' : 'unshare';
if (op === 'unshare' && !this.#shared(t.id, to)) fail('share-not-found');
if (op === 'share') {
if (to.startsWith('user:') && !this.#covered(to.slice(5), s.business, t)) fail('share-not-covered');
if (this.#shared(t.id, to)) fail('share-exists');
}
this.#store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', this.#now(), s.business, t.id, op, to, this.#owner(s));
return { thread: t.id, to, op };
}
// The shares in force on the caller's own thread, oldest first.
case 'thread.shares': {
if (!s.user) this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
return this.#store.all(
"SELECT target AS \"to\",at,by FROM thread_shares x WHERE thread=? AND op='share' AND seq=(SELECT max(seq) FROM thread_shares WHERE thread=x.thread AND target=x.target) ORDER BY seq",
t.id,
);
}
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
case 'session.list': {
@@ -5958,15 +5862,14 @@ export class Broker {
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
// Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out
// in the query, so a page of them doesn't come back short. Row 206 (193b2): the threads
// #readable admits are the share exception.
// in the query, so a page of them doesn't come back short; #readable is false until 193b2,
// which adds the share exception to this clause too.
if (this.#hiddenMessage(s, a.id)) fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ? AND t.id NOT IN (SELECT value FROM json_each(?))) ORDER BY seq LIMIT ${REPLIES}`,
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
this.#owner(s) || null,
JSON.stringify(this.#readableThreads(s).map((t) => t.id)),
);
}
default:
-15
View File
@@ -1,15 +0,0 @@
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+2 -8
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
export const SCHEMA_VERSION = '3m';
export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a';
export const SCHEMA_VERSION = '3l';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -72,11 +72,6 @@ const migrations = [{
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -92,7 +87,6 @@ const timedTables = [
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
'instance_names',
'usage_records',
];
@@ -17,8 +17,7 @@ const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url),
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -85,7 +84,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -93,7 +91,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -143,7 +141,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -155,14 +153,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3m ready/);
assert.match(migrated.stdout, /bus schema 3l ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+1 -1
View File
@@ -191,7 +191,7 @@ test('broker process: launch ops authorize role.launch, record refusals and end
assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'pm' })).result, []);
// The launcher's thread view, through the same child.
assert.deepEqual((await ask(child, { op: 'threadsState', business: 'demo' })).result, [
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null },
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null, session: 'th-1', legacy: true },
]);
// And its idle release (Dewey's F3, 28591): the op reaches the broker, which refuses a run
// that doesn't hold the thread.
-480
View File
@@ -1,480 +0,0 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+6 -8
View File
@@ -34,12 +34,11 @@ function source3j(t) {
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -75,7 +74,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -97,7 +95,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -123,7 +121,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -134,14 +132,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+6 -8
View File
@@ -20,8 +20,7 @@ const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hos
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -41,7 +40,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -78,7 +77,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -102,7 +100,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -127,7 +125,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -138,14 +136,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+2 -3
View File
@@ -522,8 +522,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
assert.equal(count('table') - 1, 23);
assert.equal(count('trigger'), 89);
assert.equal(count('table') - 1, 22);
assert.equal(count('trigger'), 86);
assert.equal(count('view'), 5);
});
+10 -10
View File
@@ -199,21 +199,21 @@ test('T2 sage-2257: a released or revoked run\'s retired generation refuses its
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
// written: no approval, delivery, read or claim state (mos-1277).
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
const refusedOnce = (fn, label) => {
const refusedOnce = (fn, label, run) => {
const from = lastSeq();
assert.throws(fn, { code: 'not-holder' }, label);
const added = store.all('SELECT kind, body FROM events WHERE seq > ? ORDER BY seq', from);
const added = store.all('SELECT kind, actor_run, body FROM events WHERE seq > ? ORDER BY seq', from);
assert.deepEqual(
added.map((e) => [e.kind, JSON.parse(e.body)]),
[['action.refused', { code: 'not-holder' }]],
added.map((e) => [e.kind, e.actor_run, JSON.parse(e.body)]),
[['action.refused', run, { code: 'not-holder' }]],
label,
);
};
const refused = (name, { d, taken }) => {
const { events, ...before } = counts();
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval');
refusedOnce(() => call(name, 'message.receive'), 'delivery');
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read');
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval', `${name}-run`);
refusedOnce(() => call(name, 'message.receive'), 'delivery', `${name}-run`);
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read', `${name}-run`);
const { events: after, ...rest } = counts();
assert.deepEqual(rest, before);
assert.equal(after, events + 3);
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
});
-439
View File
@@ -1,439 +0,0 @@
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { access, call, code, base, A, p1, rows } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, A);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+1 -3
View File
@@ -139,9 +139,7 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
]);
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
// rather than leaving the set below with the verb it added (M3x, M6x).
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
'thread.share', 'thread.unshare', 'thread.shares']);
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others) {
+117 -50
View File
@@ -75,6 +75,15 @@
// threadStore()), so the next run for the thread resumes the harness
// session (REQ-THREAD-2).
//
// Row 269 (264b, #1774): threadsState has one row per session of each
// thread, and everything above is per session. Each session has its own
// store (session.mjs sessionStore()) and its own workspace under
// session-workspaces/ (sessionWorkspace()), which a lock beside it keeps to
// one writer from before the clones until the run's proven exit. A legacy
// session (op legacy-3l) keeps the thread's store, and its new workspace
// shows inside the sandbox at the old instance path, the cwd its history
// recorded. The broker's half of the fence is its per-session claim.
//
// Landing runs (row 156, #1651). Every launch of a non-thread instance
// reads the broker's landHolders (launch op): the approved, unspent merge
// decisions each non-thread instance raised. The instance's entries go into
@@ -121,12 +130,14 @@
// processing-halted.
// - delete: instance-running against the registry, then lifecycleDelete
// (the writer's commit), then the rename of workspaces/<b>/I/ to
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
// workspace-rename-failed with the transaction; a later delete retries it.
// workspaces/<b>/.retired/I-<transaction>/, and of
// session-workspaces/<b>/I/ to session-workspaces/<b>/.retired/I-<transaction>/
// (row 269). A failed rename closes it workspace-rename-failed with the
// transaction; a later delete retries it.
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
@@ -142,8 +153,10 @@ import {
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
threadStore,
sessionExit,
sessionStore,
sessionWorkspace,
lockSessionWorkspace,
spawnSession,
workspaceDir,
writeSessionFile,
@@ -237,6 +250,9 @@ export function createLauncher({
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot, { readOnly: !launches });
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
const survivors = [];
const liveRun = (s) => startTimeOf(s.pid) === s.startTime || startTimeOf(s.runnerPid) === s.runnerStartTime;
const children = new Map();
let chain = Promise.resolve();
let closed = false;
@@ -278,7 +294,7 @@ export function createLauncher({
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
async function check(instance, { pm = false, thread = null, human = false } = {}) {
async function check(instance, { pm = false, thread = null, session = null, human = false } = {}) {
const admissionGeneration = admission().generation;
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
@@ -286,7 +302,12 @@ export function createLauncher({
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (registry.running(b.id, instance, thread)) throw new Refusal("instance-running");
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
if (registry.running(b.id, instance, thread, session)) throw new Refusal("instance-running");
// Row 269 (Q2): a run an earlier host started with no session works in the instance's old
// workspace and the thread's store, so no session of the instance starts while one lives.
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
@@ -378,29 +399,59 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false, admissionGeneration }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) {
admission(admissionGeneration);
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
// A thread session's store is checked first, so a bad id or an unsafe
// directory launches nothing.
let store = null;
if (thread) {
try {
store = threadStore(dataRoot, b.id, thread);
store = sessionStore(dataRoot, b.id, thread, session, { legacy, legacyThread });
} catch (e) {
throw new Refusal("thread-store-refused", e.message);
throw new Refusal(e.code === "legacy-store-unmapped" ? e.code : "thread-store-refused", e.message);
}
}
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
admission(admissionGeneration);
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
// checks hold only while no session writes the workspace: a workspace
// is one instance's, check() refused instance-running, and launches are
// serialized, so none does.
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
// checks hold only while no session writes the workspace. A non-thread
// workspace is one instance's, check() refused instance-running, and
// launches are serialized. A thread session's workspace is its own (row
// 269), and its lock refuses a second writer, whichever host asks.
let workspace;
let workspaceDest = null;
let lock = null;
if (thread) {
try {
const live = registry.sessions.filter((s) => s.business === b.id && s.workspace).map((s) => s.workspace);
workspace = sessionWorkspace(dataRoot, b.id, instance, session, { live });
lock = lockSessionWorkspace(workspace, { hostPid: process.pid, hostStartTime: startTimeOf(process.pid), business: b.id, instance, thread, session });
} catch (e) {
if (["session-workspace-unsafe", "session-workspace-overlap", "session-workspace-busy"].includes(e.code)) throw new Refusal(e.code, e.message);
throw e;
}
if (legacy) workspaceDest = workspaceDir(dataRoot, b.id, instance);
} else {
workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
}
try {
return await startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock });
} catch (e) {
// Every failure path in startLocked has proven the run's exit, or never spawned one.
lock?.release();
throw e;
}
}
// The rest of start(): clones, bundle, spawn, bind, register. The lock, when there is one, is
// released at the run's close.
async function startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock }) {
// What the session sees: a legacy session's workspace at the old instance path.
const inSandbox = workspaceDest ?? workspace;
let checkouts;
try {
checkouts = await withProcessing(dataRoot, () => {
@@ -416,7 +467,7 @@ export function createLauncher({
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
@@ -440,7 +491,7 @@ export function createLauncher({
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
harness,
route,
routeReason,
@@ -471,7 +522,7 @@ export function createLauncher({
brokerSocket: host.path,
launchSocket: launchSocketPath(dataRoot),
gitSocket: git,
workspace,
workspace: inSandbox,
sessionDir,
adapter,
bundle: files,
@@ -484,6 +535,7 @@ export function createLauncher({
const paths = {
workspace,
workspaceDest,
adapter,
harnessMounts,
brokerSocket: host.path,
@@ -519,11 +571,12 @@ export function createLauncher({
updateRuntime(dataRoot, managed.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
admission(admissionGeneration);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}) });
bound = true;
admission(admissionGeneration);
entry = {
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -552,13 +605,15 @@ export function createLauncher({
const ended = await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
// Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row.
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
record({ event: "end", instance, ...(thread ? { thread, session } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
try { unregisterRuntime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt cleanup refused`); }
try { lock?.release(); }
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
children.delete(run);
stopping.delete(run);
spawnedAt.delete(run);
@@ -566,7 +621,7 @@ export function createLauncher({
refusedRelease.delete(run);
}
});
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, ...(thread ? { thread, session } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
@@ -600,9 +655,12 @@ export function createLauncher({
}
const now = Date.now();
const ms = (at) => (at ? Date.parse(at) : 0);
// Row 269: a thread with a legacy session keeps its old store for that session only.
const legacyThreads = new Set(threads.filter((t) => t.legacy).map((t) => t.thread));
for (const t of threads) {
if (closed) return;
const s = registry.running(b.id, t.instance, t.thread);
const key = `${t.thread}/${t.session}`;
const s = registry.running(b.id, t.instance, t.thread, t.session);
if (s) {
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
if (!stopping.has(s.run) && now - last >= b.launch.threadIdleSeconds * 1000) {
@@ -616,7 +674,7 @@ export function createLauncher({
// A turn in progress, or a message since the snapshot: the run keeps going.
if (!idle.released) continue;
stopping.add(s.run);
record({ event: "thread-idle", instance: t.instance, thread: t.thread, run: s.run });
record({ event: "thread-idle", instance: t.instance, thread: t.thread, session: t.session, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
@@ -629,20 +687,21 @@ export function createLauncher({
// the work (it died before its claim, say) waits for the next one
// rather than start again every tick. A refused start isn't a start.
const signature = `${t.lastMessage}|${t.lastOpen}`;
if (startedFor.get(t.thread) === signature) continue;
if (startedFor.get(key) === signature) continue;
await serialize(async () => {
if (closed || registry.running(b.id, t.instance, t.thread)) return;
if (closed || registry.running(b.id, t.instance, t.thread, t.session)) return;
try {
await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...(await check(t.instance, { thread: t.thread })) });
startedFor.set(t.thread, signature);
refusedThreads.delete(t.thread);
const where = { thread: t.thread, session: t.session };
await start({ instance: t.instance, launchedBy: b.human, ...where, legacy: t.legacy, legacyThread: legacyThreads.has(t.thread), ...(await check(t.instance, where)) });
startedFor.set(key, signature);
refusedThreads.delete(key);
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
// Logged once per code, not once per tick.
if (refusedThreads.get(t.thread) === code) return;
refusedThreads.set(t.thread, code);
log(`thread ${t.thread} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
if (refusedThreads.get(key) === code) return;
refusedThreads.set(key, code);
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
}
});
}
@@ -849,20 +908,23 @@ export function createLauncher({
const transaction = out?.transaction ?? null;
if (transaction === null) return { refusal: "workspace-rename-failed" };
const bot = out.bot ? { bot: out.bot } : {};
const from = workspaceDir(dataRoot, b.id, r.instance);
if (!existsSync(from)) return { transaction, ...bot };
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
// whole. A retry moves whichever is still in place.
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
if (!existsSync(from)) continue;
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
return { transaction, ...bot };
}
@@ -958,10 +1020,15 @@ export function createLauncher({
process.kill(s.pid, "SIGKILL");
} catch {}
const end = Date.now() + 5000;
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
while (liveRun(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
// Row 269 (Q2): the session or its runner outlived the kill, or wasn't a session to kill.
if (liveRun(s)) {
survivors.push(s);
log(`run ${s.run} (${s.instance}) from an earlier host still runs (pid ${s.pid}, runner ${s.runnerPid}): no session of ${s.instance} starts until it exits`);
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}) });
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
@@ -969,7 +1036,7 @@ export function createLauncher({
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
+18
View File
@@ -0,0 +1,18 @@
// A stand-in harness turn for the session launch tests (row 269, #1774).
// thread-adapter.mjs's turn, plus the run's cwd and the names in it, so a
// test reads from the reply which workspace the session saw and at what
// in-sandbox path. It keeps a history in $MOSAIC_SESSION_DIR, the session's
// store.
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const request = process.env.MOSAIC_REQUEST ?? "";
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
const cwd = process.cwd();
const entries = readdirSync(cwd).sort();
appendFileSync(historyFile, `${body}\n`);
process.stdout.write(`THREAD ${JSON.stringify({ body, before, cwd, entries })}\n`);
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
+414
View File
@@ -0,0 +1,414 @@
// Session runs (row 269, 264b, #1774): every session of a thread runs on its
// own, with its own store under threads/<b>/<t>/sessions/<s>/ and its own
// workspace under session-workspaces/<b>/<instance>/<s>/, and a lock beside
// the workspace keeps it to one writer. A legacy session (op legacy-3l) keeps
// the thread's old store, and its workspace shows inside the sandbox at the
// old instance path. Real runners in the bwrap sandbox; a stub adapter keeps
// a history in the store and answers with what it found, its cwd and what
// the cwd holds.
//
// The host is thread-launch.test.mjs's: startBroker on bootConfig's config,
// with the ops the broker child forwards, and the human capability from
// bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { transition } from "../../processing-control/src/state.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { startTimeOf } from "../../seat/src/proc.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
// A thread made by hand gets the default session the 3k-3l migration gives a legacy thread, with
// the thread's id (as in thread-launch.test.mjs).
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/session-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
// A mutant's kill is an assertion failure, not a timeout (sage-2707), so the waits a mutant could
// stall return what they last saw and the assertions after them decide.
async function settle(fn, ms = 20000) {
const end = Date.now() + ms;
let v;
while (Date.now() < end) {
v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
return v;
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// Each directory made 0700, as the store and workspace checks want.
function privateDir(...parts) {
for (let i = 1; i <= parts.length; i++) {
const d = join(...parts.slice(0, i));
if (!existsSync(d)) mkdirSync(d, { mode: 0o700 });
if (i > 1) chmodSync(d, 0o700);
}
return join(...parts);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 3, idle = 1 } = {}) {
const root = tmp(t, "mosaic-session-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. `ops` replaces launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
const sessionWs = (f, session) => join(f.dataRoot, "session-workspaces", "acme", "assistant", session);
// The refusals once there are n, or once anything launched.
const outcome = async (f, n = 1) => {
await settle(() => events(f, "refused").length >= n || events(f, "launch").length);
return events(f, "refused").map((e) => [e.session, e.code]);
};
const historyOf = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
// Send to one session of a thread; reply() waits for the assistant's RESULT reply in that session.
function send(call, thread, session, body) {
const sent = call("thread.send", { thread, ...(session ? { session } : {}), body });
const reply = async () => {
const r = await until(() => call("thread.messages", { thread, ...(session ? { session } : {}) }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(r.from_role, "assistant");
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
};
return { sent, reply };
}
// A thread made with thread.create, its default session and a second named one.
function twoSessions(call) {
const project = call("project.create", { name: "stack work", repo: "stack" }).id;
const A = call("thread.create", { project, name: "alpha" }).id;
const [{ id: D }] = call("session.list", { thread: A });
const S = call("session.create", { thread: A, name: "second", op: "op-second" }).id;
return { project, A, D, S };
}
test("REQ-THREAD-2 REQ-LAUNCH-1 two sessions of one thread, and another thread's default, run at once, each with its own store and workspace; a restart of one resumes only that one", { skip, timeout: 90000 }, async (t) => {
const prepared = prepare(t, { idle: 5 });
const { f } = prepared;
const host = await start(t, prepared);
const { project, A, D, S } = twoSessions(host.call);
const B = host.call("thread.create", { project, name: "beta" }).id;
const [{ id: BD }] = host.call("session.list", { thread: B });
const toD = send(host.call, A, D, "first for D");
const toS = send(host.call, A, S, "first for S");
const toB = send(host.call, B, null, "first for B");
// All three run together (M1, M2: a shared workspace would refuse the second start busy).
await settle(() => readSessions(f.dataRoot).length === 3 || events(f, "refused").length);
assert.deepEqual(events(f, "refused"), []);
assert.deepEqual(readSessions(f.dataRoot).map((s) => [s.thread, s.session]).sort(), [[A, D], [A, S], [B, BD]].sort());
const [sawD, sawS, sawB] = await Promise.all([toD.reply(), toS.reply(), toB.reply()]);
// Each session's history is its own (M5), and each runs in its own workspace at its own path (M8).
assert.deepEqual([sawD.body, sawD.before, sawD.cwd], ["first for D", [], sessionWs(f, D)]);
assert.deepEqual([sawS.body, sawS.before, sawS.cwd], ["first for S", [], sessionWs(f, S)]);
assert.deepEqual([sawB.body, sawB.before, sawB.cwd], ["first for B", [], sessionWs(f, BD)]);
const store = (thread, session) => join(f.dataRoot, "threads", "acme", thread, "sessions", session, "session", "history");
assert.deepEqual([historyOf(store(A, D)), historyOf(store(A, S)), historyOf(store(B, BD))], ["first for D\n", "first for S\n", "first for B\n"]);
for (const thread of [A, B]) assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
assert.equal(existsSync(join(f.dataRoot, "workspaces", "acme", "assistant")), false, "no thread session uses the old instance workspace");
for (const e of events(f, "launch")) assert.ok([D, S, BD].includes(e.session), JSON.stringify(e));
// All three stop when idle, and each lock goes with its run.
await until(() => events(f, "end").length === 3, 30000);
assert.deepEqual(readSessions(f.dataRoot), []);
for (const s of [D, S, BD]) assert.equal(existsSync(`${sessionWs(f, s)}.lock`), false, s);
// The named session's next message starts only that session (M9), which finds only its history.
const again = await send(host.call, A, S, "second for S").reply();
assert.deepEqual([again.before, again.cwd], [["first for S"], sessionWs(f, S)]);
assert.deepEqual(events(f, "launch").slice(3).map((e) => [e.thread, e.session]), [[A, S]]);
assert.equal(historyOf(store(A, D)), "first for D\n");
});
// D9 (M7): a thread from before 3l. Its legacy session resumes from the thread's old store, and
// its new workspace shows at the old instance path, which the transcripts recorded as the cwd.
// The old host workspace isn't mounted.
test("REQ-THREAD-2 REQ-LAUNCH-1 a legacy session resumes the thread's old store at the old in-sandbox cwd, in a workspace of its own", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const store = new Store(f.dataRoot);
const at = () => new Date().toISOString();
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", "th-legacy", at(), "acme", "p-hand", "assistant", "jason", "old");
for (const sql of DEFAULT_SESSION) store.run(sql, "th-legacy");
store.close();
const old = privateDir(f.dataRoot, "threads", "acme", "th-legacy");
writeFileSync(join(privateDir(old, "session"), "history"), "old sentinel\n");
privateDir(old, "claude-projects");
writeFileSync(join(privateDir(f.dataRoot, "workspaces", "acme", "assistant"), "old-file"), "the 264a workspace\n");
const host = await start(t, prepared);
const saw = await send(host.call, "th-legacy", null, "after 3l").reply();
assert.deepEqual(saw.before, ["old sentinel"]);
assert.equal(saw.cwd, join(f.dataRoot, "workspaces", "acme", "assistant"));
assert.equal(saw.entries.includes("old-file"), false, "the old host workspace isn't mounted");
assert.equal(readFileSync(join(old, "session", "history"), "utf8"), "old sentinel\nafter 3l\n");
assert.equal(existsSync(join(old, "sessions")), false);
assert.ok(existsSync(sessionWs(f, "th-legacy")));
assert.deepEqual(readdirSync(join(f.dataRoot, "workspaces", "acme", "assistant")), ["old-file"]);
assert.deepEqual(events(f, "launch").map((e) => [e.thread, e.session]), [["th-legacy", "th-legacy"]]);
});
// T2, M3: another host's live lock on the session's workspace refuses the start before any
// checkout. Once that host is gone the lock is stale, and the next start takes it over.
test("REQ-THREAD-2 REQ-LAUNCH-1 a live lock on a session workspace refuses session-workspace-busy before any checkout; a stale one is taken over", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, S } = twoSessions(host.call);
const other = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => other.once("exit", r));
t.after(() => other.kill("SIGKILL"));
const otherStart = await until(() => startTimeOf(other.pid));
privateDir(f.dataRoot, "session-workspaces", "acme", "assistant");
const lock = `${sessionWs(f, S)}.lock`;
const held = `${JSON.stringify({ hostPid: other.pid, hostStartTime: otherStart, business: "acme", instance: "assistant", thread: A, session: S })}\n`;
writeFileSync(lock, held, { mode: 0o600 });
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "session-workspace-busy"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(sessionWs(f, S)), [], "no checkout");
assert.equal(readFileSync(lock, "utf8"), held);
other.kill("SIGKILL");
await gone;
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
assert.equal(existsSync(lock), false);
});
// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is
// still live, so no session of it starts until that run has exited. A sleep child stands in for
// its runner; its outer pid is gone, so recover() has nothing to kill and keeps it as a survivor.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session start refuses legacy-run-live while an earlier host's sessionless run lives, and starts once it has exited", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const runner = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => runner.once("exit", r));
t.after(() => runner.kill("SIGKILL"));
const runnerStartTime = await until(() => startTimeOf(runner.pid));
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: "th-old", run: "run-left", harness: "pi", pid: 2 ** 22 + 7, startTime: "gone", runnerPid: runner.pid, runnerStartTime });
const host = await start(t, prepared);
assert.ok(host.logs.includes(`run run-left (assistant) from an earlier host still runs (pid ${2 ** 22 + 7}, runner ${runner.pid}): no session of assistant starts until it exits`), host.logs.join("\n"));
const { A, S } = twoSessions(host.call);
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "legacy-run-live"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), [], "nothing spawned");
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(sessionWs(f, S)), false, "refused before the workspace");
runner.kill("SIGKILL");
await gone;
assert.equal(await settle(() => events(f, "launch").length), 1, "it starts once the run has exited");
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
});
// T2, C3: a symlinked session root refuses session-workspace-unsafe, at the segment check.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session workspace root refuses session-workspace-unsafe and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(f.dataRoot, "session-workspaces"));
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-unsafe"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
});
// T2, M4: every segment of the session path is a real directory, but workspaces/<b> is a symlink
// to it, so only the realpath compare finds that the session workspace holds the legacy root.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session workspace that holds the legacy workspace root refuses session-workspace-overlap and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const ws = privateDir(f.dataRoot, "session-workspaces", "acme", "assistant", D);
symlinkSync(ws, join(privateDir(f.dataRoot, "workspaces"), "acme"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-overlap"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(ws), []);
});
// T2: a symlink among the store's segments refuses thread-store-refused before the workspace.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session store refuses thread-store-refused and makes no workspace", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(privateDir(f.dataRoot, "threads", "acme", A), "sessions"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "thread-store-refused"]]);
assert.match(events(f, "refused")[0].why, /is unsafe$/);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
assert.equal(existsSync(join(f.dataRoot, "session-workspaces")), false);
});
// T2, M6 and the halt fence (C2). The credentialStatus op runs in start() after check() returned.
// Its first call halts and resumes, a new generation with no halt, so only start()'s own
// admission(admissionGeneration) refuses, before the session workspace is made. On the retry,
// the recheck's mayLaunch op (inside check(), after the lock is taken) halts once it has answered,
// and the recheck's admission() refuses processing-halted. Neither attempt spawns, and the second releases its lock.
test("REQ-THREAD-2 REQ-LAUNCH-1 a named session's start refuses processing-generation-changed, then processing-halted, after check() and before any spawn", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let credentials = 0;
let halts = 0;
const halt = () => transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: true });
const host = await start(t, prepared, {
ops: (runtime) => ({
credentialStatus: (m) => {
if (m.role === "assistant" && ++credentials === 1) {
transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: false, generation: halt().generation });
halts++;
}
return runtime.credentialStatus(m.business, m.role);
},
// The halt lands after the op answers: halted inside it, the bus's own fence
// (runtime.mjs processingWork) throws and the launcher refuses limit-check-failed.
mayLaunch: async (m) => {
const may = await runtime.mayLaunch(m);
if (m.instance === "assistant" && credentials === 2 && halts === 1) {
halt();
halts++;
}
return may;
},
}),
});
const { A, S } = twoSessions(host.call);
send(host.call, A, S, "for S");
const refused = await outcome(f, 2);
assert.deepEqual(refused, [[S, "processing-generation-changed"], [S, "processing-halted"]]);
assert.deepEqual([credentials, halts], [2, 2], "check() passed both times");
assert.equal(events(f, "launch").length, 0);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme")), false, "no run directory");
assert.ok(existsSync(sessionWs(f, S)), "the retry made the workspace");
assert.equal(existsSync(`${sessionWs(f, S)}.lock`), false, "and released its lock");
});
+1 -1
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
+2 -1
View File
@@ -179,10 +179,11 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's s
assert.deepEqual(first.saw, { body: "first for A", before: [], transcripts: [] });
const [run1] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).map((e) => e.run);
assert.ok(run1);
const store = join(f.dataRoot, "threads", "acme", A);
const store = join(f.dataRoot, "threads", "acme", A, "sessions", host.runtime.threadsState("acme").find((x) => x.thread === A).session);
assert.equal(readFileSync(join(store, "session", "history"), "utf8"), "first for A\n");
assert.deepEqual(readdirSync(join(store, "claude-projects")), ["turn-1.jsonl"]);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme", run1, "session")), false, "a thread run keeps no per-run session directory");
assert.equal(existsSync(join(f.dataRoot, "threads", "acme", A, "session")), false, "a thread.create session keeps no thread-level store");
await idleStop(f, host, A, run1);
// A second run of the same thread finds the first run's history.
+7 -4
View File
@@ -97,9 +97,12 @@ function systemBinds(node = process.execPath) {
// the harness module's mounts (row 65: its mounts(), [{ src, dest, why }],
// read-only), the broker socket, the launch socket (null for a role that may
// not launch), the git socket (null for a role that neither pushes nor
// reviews), the thread's session store (session.mjs threadStore(), or null)
// and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
// reviews), the thread's session store (session.mjs threadStore() or
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
// workspace shows inside the sandbox when that isn't its host path: a legacy
// thread session's, so its harness finds the cwd its history recorded.
// `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
@@ -109,7 +112,7 @@ export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSo
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
+176 -17
View File
@@ -20,17 +20,22 @@
// every launch, refusal and end
// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
// across launches
// threads/<business>/<thread>/ a thread's session store (0700), kept
// across the thread's runs (row 121):
// threads/<business>/<thread>/ a legacy thread session's store (0700),
// kept across its runs (row 121):
// session/ and claude-projects/
// threads/<business>/<thread>/sessions/<session>/
// any other session's store (row 269)
// session-workspaces/<business>/<instance>/<session>/
// a thread session's workspace (0700),
// with <session>.lock beside it
// bus-host/sessions.json the running sessions (0600), written
// only by the host; `mosaic stop` reads it
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { appendFileSync, closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { constants } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, sep } from "node:path";
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs";
import { SeatError } from "./seat.mjs";
@@ -38,7 +43,10 @@ import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export { REPO };
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
export const SESSIONS_VERSION = 1;
// Row 269 (264b): 2 adds `session` and `workspace` to a thread run's entry.
// readSessions still reads a version 1 file an earlier host left.
export const SESSIONS_VERSION = 2;
const SESSIONS_READ = [1, 2];
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
export const launchSocketPath = (dataRoot) => join(dataRoot, "bus-host", "launch.sock");
@@ -47,6 +55,7 @@ export const gitSocketPath = (dataRoot) => join(dataRoot, "bus-host", "git.sock"
export const runsDir = (dataRoot, business) => join(dataRoot, "launches", business);
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
export const sessionWorkspaceDir = (dataRoot, business, instance, session) => join(dataRoot, "session-workspaces", business, instance, session);
// Row 121 (#1615): a thread's session store, which outlives each of the
// thread's runs so the next run resumes the harness session (REQ-THREAD-2).
@@ -56,24 +65,174 @@ export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "wo
// is the run's sessionDir; claude-projects/ is bound at the run's
// home/.claude/projects (sandbox.mjs binds()). Throws SeatError.
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
export function threadStore(dataRoot, business, thread) {
for (const [what, x] of [["business", business], ["thread", thread]]) {
if (!segment(x)) throw new SeatError(`thread store: ${what} ${JSON.stringify(x)} isn't an id`, 2);
const seatError = (message, code) => Object.assign(new SeatError(message, 2), { code });
function segments(what, pairs, code) {
for (const [name, x] of pairs) {
if (!segment(x)) throw seatError(`${what}: ${name} ${JSON.stringify(x)} isn't an id`, code);
}
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
for (const d of [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects]) {
}
// Makes each directory in order, then checks it is ours, a real directory and private.
function privateDirs(what, dirs, code) {
for (const d of dirs) {
try {
mkdirSync(d, { mode: 0o700 });
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(d);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw new SeatError(`thread store: ${d} is unsafe`, 2);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw seatError(`${what}: ${d} is unsafe`, code);
}
}
export function threadStore(dataRoot, business, thread) {
segments("thread store", [["business", business], ["thread", thread]], "thread-store-refused");
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects], "thread-store-refused");
return out;
}
// Row 269 (264b, #1774): one session's store. A legacy session (op legacy-3l,
// whose id is its thread's) keeps the thread's store, so its harness resumes
// the history it has; any other session gets its own under sessions/, with
// the same checks on every segment and directory. Same shape as threadStore.
// legacyThread: the thread has a legacy session. When it has none, a
// threads/<b>/<t>/session left by a 264a run of the thread's default session
// refuses legacy-store-unmapped, so that history isn't silently left behind.
export function sessionStore(dataRoot, business, thread, session, { legacy = false, legacyThread = legacy } = {}) {
if (legacy) return threadStore(dataRoot, business, thread);
segments("thread store", [["business", business], ["thread", thread], ["session", session]], "thread-store-refused");
const threadDir = join(dataRoot, "threads", business, thread);
if (!legacyThread) {
let old = true;
try {
lstatSync(join(threadDir, "session"));
} catch (e) {
if (e.code !== "ENOENT") throw e;
old = false;
}
if (old) throw seatError(`thread store: ${join(threadDir, "session")} holds history no legacy session maps`, "legacy-store-unmapped");
}
const dir = join(threadDir, "sessions", session);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), threadDir, join(threadDir, "sessions"), dir, out.session, out.projects], "thread-store-refused");
return out;
}
const inside = (a, b) => a === b || a.startsWith(b + sep);
const realOrNull = (p) => {
try {
return realpathSync(p);
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
};
// Row 269: a thread session's workspace, session-workspaces/<b>/<i>/<s>. Each
// segment is an id and each directory ours, real and private
// (session-workspace-unsafe). Then the realpaths: it may not be inside, or
// contain, the legacy workspaces root, its business and instance directories
// or any entry of either (old instance, .retired and task workspaces), nor
// sit above or below a `live` workspace (session-workspace-overlap). The same
// path as a live one is left to the lock, which refuses session-workspace-busy.
export function sessionWorkspace(dataRoot, business, instance, session, { live = [] } = {}) {
segments("session workspace", [["business", business], ["instance", instance], ["session", session]], "session-workspace-unsafe");
const root = join(dataRoot, "session-workspaces");
const path = join(root, business, instance, session);
privateDirs("session workspace", [root, join(root, business), join(root, business, instance), path], "session-workspace-unsafe");
const real = realpathSync(path);
const legacy = join(dataRoot, "workspaces");
const named = [legacy, join(legacy, business), join(legacy, business, instance)];
const entries = (d) => {
try {
return readdirSync(d).map((x) => join(d, x));
} catch (e) {
if (e.code === "ENOENT" || e.code === "ENOTDIR") return [];
throw e;
}
};
for (const p of [...named, ...entries(legacy), ...entries(join(legacy, business))]) {
const other = realOrNull(p);
if (other && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the legacy workspace ${p}`, "session-workspace-overlap");
}
for (const p of live) {
const other = realOrNull(p);
if (other && other !== real && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the live workspace ${p}`, "session-workspace-overlap");
}
return path;
}
// Row 269: the single-writer lock on a session workspace, <workspace>.lock,
// beside it and outside every mount. It names the host (pid, start time) and,
// once spawned, the run's outer process. A lock whose host and run are both
// gone is stale and is taken over; any other refuses session-workspace-busy.
// Returns { file, update(fields), release() }.
const lockAlive = (pid, start) => Number.isSafeInteger(pid) && pid > 0 && start != null && startTimeOf(pid) === start;
export function lockSessionWorkspace(workspace, owner) {
const file = `${workspace}.lock`;
let held = { ...owner };
const write = (flag) => writeFileSync(file, `${JSON.stringify(held)}\n`, { mode: 0o600, flag });
const busy = (why) => seatError(`session workspace ${workspace} is busy: ${why}`, "session-workspace-busy");
for (let attempt = 0; attempt < 2; attempt++) {
try {
write("wx");
// One release per lock: a second unlink could take a lock another host has written since.
let released = false;
return {
file,
update(fields) {
if (released) return;
held = { ...held, ...fields };
write("w");
},
release() {
if (released) return;
released = true;
try {
unlinkSync(file);
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
},
};
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
let raw;
try {
raw = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
let other = null;
try {
other = JSON.parse(raw);
} catch {}
// An unreadable lock may be one being written, so it counts as held.
if (!other || typeof other !== "object") throw busy(`${file} is unreadable`);
if (lockAlive(other.hostPid, other.hostStartTime)) throw busy(`host pid ${other.hostPid} holds it`);
if (lockAlive(other.runPid, other.runStartTime)) throw busy(`run pid ${other.runPid} holds it`);
// Stale. Move it aside; if what moved isn't what was judged stale, another
// host took it over in between, so it goes back and this start refuses.
const aside = `${file}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
try {
renameSync(file, aside);
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
if (readFileSync(aside, "utf8") !== raw) {
try {
linkSync(aside, file);
} catch {}
unlinkSync(aside);
throw busy(`another host took ${file} over`);
}
unlinkSync(aside);
}
throw busy(`${file} could not be taken`);
}
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -212,10 +371,10 @@ export class Registry {
count(business, provider, model = undefined) {
return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
// A thread instance runs once per thread (row 121), so `thread` is part of
// the key; null is a session that holds no thread.
running(business, instance, thread = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
// A thread instance runs once per thread session (row 121, row 269), so
// `thread` and `session` are part of the key; null is an entry without one.
running(business, instance, thread = null, session = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread && (s.session ?? null) === session) ?? null;
}
#write() {
if (this.#readOnly) return;
@@ -238,7 +397,7 @@ export function readSessions(dataRoot) {
} catch {
throw new SeatError(`sessions file is unreadable: ${file}`, 2);
}
if (doc?.sessionsVersion !== SESSIONS_VERSION || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
if (!SESSIONS_READ.includes(doc?.sessionsVersion) || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
}
@@ -0,0 +1,126 @@
// Row 269 (264b, #1774): a thread session's store, its workspace and the
// single-writer lock beside the workspace (session.mjs sessionStore(),
// sessionWorkspace(), lockSessionWorkspace()), and sessions.json v2.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { startTimeOf } from "../src/proc.mjs";
import { SeatError } from "../src/seat.mjs";
import { lockSessionWorkspace, readSessions, Registry, sessionsFile, sessionStore, sessionWorkspace, threadStore } from "../src/session.mjs";
function scratch(t) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-ws-")));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
const mode = (p) => statSync(p).mode & 0o777;
const refuses = (code) => (e) => e instanceof SeatError && e.code === code;
test("REQ-THREAD-2 sessionStore: a legacy session keeps the thread's store, any other gets sessions/<s>/, each segment checked first", (t) => {
const root = scratch(t);
assert.deepEqual(sessionStore(root, "acme", "th-1", "th-1", { legacy: true }), threadStore(root, "acme", "th-1"));
const s = sessionStore(root, "acme", "th-1", "s-2", { legacyThread: true });
const dir = join(root, "threads", "acme", "th-1", "sessions", "s-2");
assert.deepEqual(s, { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") });
for (const d of [dirname(dir), dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d);
for (const [thread, session] of [["..", "s"], ["th-1", ".."], ["th-1", "a/b"], ["th-1", null]])
assert.throws(() => sessionStore(root, "acme", thread, session, { legacyThread: true }), refuses("thread-store-refused"), `${thread}/${session}`);
assert.deepEqual(readdirSync(join(root, "threads", "acme", "th-1", "sessions")), ["s-2"], "a refused call makes nothing");
});
test("REQ-THREAD-2 sessionStore refuses legacy-store-unmapped for a thread whose old store no legacy session maps", (t) => {
const root = scratch(t);
threadStore(root, "acme", "th-1");
assert.throws(() => sessionStore(root, "acme", "th-1", "s-1"), (e) => refuses("legacy-store-unmapped")(e) && /holds history no legacy session maps$/.test(e.message));
assert.equal(existsSync(join(root, "threads", "acme", "th-1", "sessions")), false);
// A thread with a legacy session: its other sessions take D2's layout.
assert.ok(sessionStore(root, "acme", "th-1", "s-1", { legacyThread: true }).dir.endsWith(join("sessions", "s-1")));
const elsewhere = join(root, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(root, "threads", "acme", "th-2"));
assert.throws(() => sessionStore(root, "acme", "th-2", "s-1"), (e) => refuses("thread-store-refused")(e) && /is unsafe$/.test(e.message));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 sessionWorkspace: private directories per session, unsafe segments and overlaps refused", (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
assert.equal(ws, join(root, "session-workspaces", "acme", "assistant", "s-1"));
for (const d of [join(root, "session-workspaces"), dirname(dirname(ws)), dirname(ws), ws]) assert.equal(mode(d), 0o700, d);
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1"), ws, "a second call reuses it");
assert.notEqual(sessionWorkspace(root, "acme", "assistant", "s-2"), ws);
for (const session of ["..", "a/b", ""]) assert.throws(() => sessionWorkspace(root, "acme", "assistant", session), refuses("session-workspace-unsafe"), session);
// A live workspace at the same path is the lock's to refuse; one above or below it overlaps.
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1", { live: [ws] }), ws);
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-3", { live: [dirname(ws)] }), refuses("session-workspace-overlap"));
// The legacy root symlinked into a session path: every segment is real, only the realpath finds it.
mkdirSync(join(root, "workspaces"), { mode: 0o700 });
symlinkSync(ws, join(root, "workspaces", "acme"));
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-1"), refuses("session-workspace-overlap"));
// A symlinked root fails the segment check first.
const other = scratch(t);
const elsewhere = join(other, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(other, "session-workspaces"));
assert.throws(() => sessionWorkspace(other, "acme", "assistant", "s-1"), refuses("session-workspace-unsafe"));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 lockSessionWorkspace: one writer; a live host or run holds it, a stale lock is taken over, an unreadable one holds", async (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
const me = { hostPid: process.pid, hostStartTime: startTimeOf(process.pid) };
const lock = lockSessionWorkspace(ws, me);
assert.equal(lock.file, `${ws}.lock`);
assert.equal(mode(lock.file), 0o600);
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
lock.update({ run: "r-1", runPid: 1, runStartTime: "x" });
assert.deepEqual(JSON.parse(readFileSync(lock.file, "utf8")), { ...me, run: "r-1", runPid: 1, runStartTime: "x" });
lock.release();
assert.equal(existsSync(lock.file), false);
// A run that outlives its host still holds it.
const run = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => run.once("exit", r));
t.after(() => run.kill("SIGKILL"));
let runStart = null;
for (let i = 0; i < 100 && !runStart; i++) {
runStart = startTimeOf(run.pid);
if (!runStart) await new Promise((r) => setTimeout(r, 20));
}
const held = `${JSON.stringify({ hostPid: 2 ** 22 + 7, hostStartTime: "gone", runPid: run.pid, runStartTime: runStart })}\n`;
writeFileSync(`${ws}.lock`, held, { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
assert.equal(readFileSync(`${ws}.lock`, "utf8"), held);
run.kill("SIGKILL");
await gone;
// Now stale: taken over, and nothing is left beside it.
const taken = lockSessionWorkspace(ws, me);
assert.deepEqual(JSON.parse(readFileSync(taken.file, "utf8")), me);
assert.deepEqual(readdirSync(dirname(ws)).sort(), ["s-1", "s-1.lock"]);
// One release per lock: a second doesn't remove a lock written since.
taken.release();
writeFileSync(`${ws}.lock`, "{}\n", { mode: 0o600 });
taken.release();
assert.equal(readFileSync(`${ws}.lock`, "utf8"), "{}\n");
// An unreadable lock may be one being written, so it holds.
writeFileSync(`${ws}.lock`, "{", { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
});
test("REQ-THREAD-2 sessions.json v2: entries carry session and workspace, running() keys on the session, a v1 file still reads", (t) => {
const root = scratch(t);
const registry = new Registry(root);
const entry = { business: "acme", instance: "assistant", thread: "th-1", session: "s-1", workspace: "/w", run: "r-1" };
registry.add(entry);
assert.equal(JSON.parse(readFileSync(sessionsFile(root), "utf8")).sessionsVersion, 2);
assert.deepEqual(registry.running("acme", "assistant", "th-1", "s-1"), entry);
assert.equal(registry.running("acme", "assistant", "th-1", "s-2"), null);
assert.equal(registry.running("acme", "assistant", "th-1"), null, "an entry with a session isn't the thread's sessionless run");
writeFileSync(sessionsFile(root), `${JSON.stringify({ sessionsVersion: 1, sessions: [{ business: "acme", instance: "assistant", thread: "th-1", run: "r-0" }] })}\n`);
assert.deepEqual(readSessions(root).map((s) => [s.run, s.session ?? null]), [["r-0", null]]);
});
+1 -1
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");