Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f71b359bd | ||
|
|
9af75dc729 | ||
|
|
7e2ead7f3a | ||
|
|
80fd455215 | ||
|
|
c8fe23a371 | ||
|
|
004f133bd6 | ||
|
|
a7661ada06 | ||
|
|
f871cb3096 | ||
|
|
0bbd23c619 | ||
|
|
0217b71c8f | ||
|
|
c6f528a32f | ||
|
|
96f243fafc | ||
|
|
c862699352 | ||
|
|
48ebee0ffe | ||
|
|
66bdd84b8f | ||
|
|
6966164f0c | ||
|
|
53bb594621 | ||
|
|
5e01c36833 | ||
|
|
9f56d2098d | ||
|
|
0599b119f4 | ||
|
|
b416d2ede9 | ||
|
|
5fc73a729c | ||
|
|
99772421ab | ||
|
|
da230ca489 | ||
|
|
4f2d1be68a | ||
|
|
d55d86bb04 | ||
|
|
c56ccf916e | ||
|
|
817e8f51b0 | ||
|
|
5d019d8768 | ||
|
|
59475fc5a8 | ||
|
|
1a268249e6 | ||
|
|
def2e79327 | ||
|
|
c30b36fffb | ||
|
|
9ae8c73acb | ||
|
|
8ecc7a239d | ||
|
|
7f57419b38 | ||
|
|
4ca8d6e949 | ||
|
|
22e5e825b0 | ||
|
|
e6dea74b22 | ||
|
|
cd2b377bbf | ||
|
|
ceccb0ef26 | ||
|
|
10ab8ea624 | ||
|
|
c539995cf4 | ||
|
|
92cc6011c8 | ||
|
|
7af1665748 | ||
|
|
2244e8bafc | ||
|
|
bde0fdec9f | ||
|
|
def677bc76 | ||
|
|
51f67f1f87 | ||
|
|
5b298bd00b | ||
|
|
565bc32c2f | ||
|
|
5140aa9e88 | ||
|
|
973cb5a872 | ||
|
|
b82ec93536 | ||
|
|
3560c450a6 | ||
|
|
4f02486d56 | ||
|
|
263ea8a9a3 | ||
|
|
6020bb43ac |
No files matched your search
@@ -16,6 +16,14 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
|
||||
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Bushroot
|
||||
|
||||
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
|
||||
that retires the control board writer on 7331 (#1696).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,292 @@
|
||||
# Row 198 design: retire the control board writer on 7331
|
||||
|
||||
Gitea #1696. Brief `docs/plans/2026-10-20_retire-control-board.md` (blob 11977a5cb, commit a31f1a0f8, Mos's
|
||||
call mos-1019). Owner Bushroot, reviewer Webby, gate owner Sage. Inventory: `agents/bushroot/work/queue-198/INVENTORY.md`
|
||||
(004f133bd, sha256 76b630ce...877).
|
||||
|
||||
Base: brief commit a31f1a0f8 is on origin `refactor` (head aa1bd209e). This branch rebases on `refactor` after
|
||||
row 192 lands (Sage's sequencing, ruling 4).
|
||||
|
||||
## Revision 5 (Webby REVIEW-r4.md, origin/webby/reviews 5a02ee395, on rev 4 9af75dc72; Mos mos-1045)
|
||||
|
||||
- **B1 (r4, the `--tester-port` swap)**: reverted. `parses_flag` is for `--board` only. The `--tester-port` grep at
|
||||
`stack-release.sh:670` stays. The `--tester-port` test row I added in rev 4 is removed.
|
||||
- **N1 (r4, stale mark)**: fixed. A write counter guards `index()` coalescing. `markSeen()` and `reply()` start a fresh
|
||||
scan and never join one. The new `board.test.mjs` case is in the new-tests table, and mutant M15 is in the table.
|
||||
- **Sequencing (sage-1386)**: row 197 lands before 198. The rebase is noted under Open points.
|
||||
|
||||
## Revision 4 (Webby REVIEW-r3.md, origin/webby/reviews c9d65664a, on rev 3 7e2ead7f3)
|
||||
|
||||
- **B1** (blocking child calls): fixed. The board's child calls become async. `createBoard` takes an async `exec`,
|
||||
and `index()`, `markSeen()` and `reply()` return promises. The sync `scan` and `replyToRow` stay for the CLI and
|
||||
their existing tests. See decision 10, the new B1 test and mutant M14.
|
||||
- **N1** (fallback copy): fixed. Decision 8 now says which copy gets the 7331 fallback.
|
||||
- **N2** (flag check matched a refusal): fixed. The parse check matches the parser branch, not the flag name. See
|
||||
decision 8. M12's target now names `--board` only in a refusal.
|
||||
|
||||
## Revision 3 (Sage rulings sage-1372, on rev 2 80fd45521)
|
||||
|
||||
- Ruling 1: `/api/seen` goes under `--send`, the same as `/api/reply`. Mos agrees (mos-1023 item 5). Both POSTs sit
|
||||
above `serve.mjs:558` (decision 5).
|
||||
- Ruling 2: the board context keeps the writer's read-only fleet default (decision 9).
|
||||
- Ruling 3: a rollback to a copy with no recorded `--board` falls back to 7331, which is dark, as it is today (decision 8).
|
||||
- Ruling 5: loopback stays read-only. No new gate in this row. The cross-uid loopback gate is row 196's. Sage took
|
||||
this to Mos (sage-1370). If Mos rules otherwise, Sage says so and this design changes.
|
||||
- Ruling 6, correction: neither the live pointer nor the live unit carries `--board`. Sage checked the live
|
||||
`ExecStart`: `cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com
|
||||
--auth-header X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Rev 2 said otherwise, and
|
||||
that was wrong. Decisions 6 and 8 are rewritten. The B2 guard stays. `webui-service.sh` lines 44, 71, 110 and 163
|
||||
still take and emit `--board`, so they are in the retirement.
|
||||
- Ruling 7: base. Brief commit a31f1a0f8 is on origin `refactor` at aa1bd209e.
|
||||
|
||||
## Revision 2 (Webby REVIEW.md, origin/webby/reviews 9afb947c7)
|
||||
|
||||
Rev 1 was c8fe23a37. Rev 2 answered B1-B3 and N1-N5 (80fd45521). Changes are marked where they land.
|
||||
|
||||
- **B1** (pinned copy): fixed. `packages/control-board/src` and `tools/tmux/agent-send.sh` join `COPY_PATHS`. The
|
||||
copy test starts the Console from a fresh copy and expects 200 on `/api/board`. See decision 7.
|
||||
- **B2** (re-pin carries `--board`): fixed. `stack-release.sh` drops `--board` when the target's `cli.mjs` lacks it,
|
||||
as `console_for_target` already does for `--tester-port`. The plan lists the drop. See decision 8.
|
||||
- **B3** (POST placement): fixed. The board POST branches sit above the refusal at `serve.mjs:558`, after the
|
||||
header and origin checks, next to bus message and chat. Loopback cannot reply or mark seen. See decision 5 and mutant M10.
|
||||
- **N1**: the board context uses the writer's default fleet source. See decision 9.
|
||||
- **N2**: the M2 check covers all of `packages/control-board/src`, not one file.
|
||||
- **N3**: the lines that change are listed by file in "Test line changes" below.
|
||||
- **N4**: the README records that loopback board reads stay open to any local process until Mos rules.
|
||||
- **N5**: row 198 lands after row 192. The board route tests rerun on the rebased tree.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **The Console calls the board's read code in-process.** A new module `packages/control-board/src/board.mjs`
|
||||
holds the route logic that `createServer` runs today: rescan, mark seen, reply and conversation read. It
|
||||
imports no `node:http` and opens no socket. Its methods are async (decision 10).
|
||||
2. **No listener anywhere in the board package.** `createServer` and `startServer` are deleted from
|
||||
`serve.mjs`. `serve.mjs` keeps its pure exports (`replyToRow`, `isLoopbackHost`, `foreignRequest`,
|
||||
`conversationResponse`, `REFUSAL_STATUS`, the reply constants). That keeps `packages/conversation/tests/flows.test.mjs`
|
||||
unchanged. The file name `serve.mjs` now misleads. Renaming it is a separate follow-up and is not in this row.
|
||||
3. **The writer's CLI refuses `serve`.** `packages/control-board/src/cli.mjs` keeps `scan`. `serve` exits 2 with a
|
||||
message that points to the Console. The default port 7331 goes away.
|
||||
4. **The Console gets the board by context, not by URL.** `startServer` in `packages/webui/src/serve.mjs` takes
|
||||
`board` as a context object (or `null` when no board is configured), not a URL. `DEFAULT_BOARD`, `boardURL`
|
||||
and the proxy `fetch` are removed.
|
||||
5. **`/api/reply` and `/api/seen` are gated by `--send`, and sit above the `:558` refusal.** Today the loopback Console
|
||||
forwards both POSTs with no `--send` check. That is a real gap, and this row closes it. Placement matters.
|
||||
In preview mode, `serve.mjs:558` refuses every non-GET before the board block, so a board branch below it never
|
||||
runs. In loopback mode, `--send` cannot be set at all (`:479`). So both POST branches go after the header and
|
||||
origin checks (`:493-516`), beside `/api/bus/message` (`:550`) and chat (`:554`), and require
|
||||
`send !== null && named`. The result: `/api/reply` is the first board write the live Console has ever allowed.
|
||||
Mos ruled it in (mos-1019), and mos-1023 item 5 confirms `/api/seen` too. In loopback mode, reply and mark-seen
|
||||
refuse with 403 `read-only`. Board reads still work there. `/api/seen` writes marks under the data root, and without
|
||||
the gate it would be the only preview-mode POST that passes without `--send`.
|
||||
6. **The retirement removes `--board` from the Console scripts.** No installed unit or live pointer carries `--board`
|
||||
(Sage checked, rev 3). So no live unit needs a flag change to start. The flag goes from `packages/webui/src/cli.mjs`
|
||||
(`:14`, `:18`, `:32`) and `scripts/webui-service.sh` (lines 44, 71, 110, 161-163). An old `--board` argument refuses
|
||||
with the usage error (exit 2). A re-pin with the flag removed is an ordinary re-pin. Cutover needs no unit edit.
|
||||
7. **The pinned copy carries the board code (B1).** The live Console runs from `copies/<sha>`. `COPY_PATHS` in
|
||||
`scripts/stack-copy.sh` (lines 23-33) lacks `packages/control-board/src` and `tools/tmux`. Without them a
|
||||
Console from a copy fails at import, and `reply` cannot find `agent-send.sh`. Both paths join `COPY_PATHS`.
|
||||
`tools/tmux` goes in whole, the same way `packages/plugins` does, because `agent-send.sh` may load its own
|
||||
siblings. The `control-board/src` closure is `conversation/src`, `discord/src` and `seat/src`, and those are already in
|
||||
the list.
|
||||
8. **Re-pin and rollback drop `--board` when the target cannot parse it (B2 guard).** The pointer's console flags
|
||||
(`P_CONSOLE`, `stack-release.sh:128`) pass through `:676` into `RUN_CONSOLE`, and `:459` passes them to
|
||||
`webui-service.sh install`. The live pointer has no `--board` (rev 3), so this is a guard for a pointer that
|
||||
carries one, such as an older pointer or a rollback target. Rule: keep `--board` only when the target's
|
||||
`packages/webui/src/cli.mjs` parses it. Otherwise drop it and report the drop in the plan. That is the same shape
|
||||
as `console_for_target` and `--tester-port` (`:667`, `:687-690`). Rollback to a copy that parses `--board` keeps
|
||||
the recorded flag. A copy that does not parse it (a new copy) gets the flag dropped and starts with no board URL,
|
||||
so its board is in-process or null, never 7331. The 7331 fallback applies only to an older copy that parses
|
||||
`--board` and has no recorded flag. That copy starts with its own default, 7331, which is dark, the same as today
|
||||
(Sage ruling 3).
|
||||
**Parse check (N2, rev 5).** "Parses it" means the target's `cli.mjs` holds the parser branch, `flag === '--board'`,
|
||||
not the flag name. The name also appears in the usage text and in refusal messages, so a plain `grep` would count
|
||||
a copy that refuses the flag as one that accepts it. One helper, `parses_flag FILE FLAG`, greps for
|
||||
`flag === 'FLAG'`. It is used for `--board` only, in `console_for_target` and the rollback path. The existing
|
||||
`--tester-port` grep at `stack-release.sh:670` stays as it is (Webby REVIEW-r4). Changing it would break the
|
||||
`stack-release.test.mjs:265` fixtures, which write a comment, not a parser line. No copy names `--tester-port` in a
|
||||
refusal today, so the gap exists only for `--board`.
|
||||
9. **The board context uses the writer's default fleet source (N1).** `--fleet` defaults to `~/.mosaic/fleet/agents`,
|
||||
as the writer did (`cli.mjs:14`). The Console reads it and never writes it. Whether the fleet is retired
|
||||
and the default becomes `none` is a later decision. This row keeps the board's contents unchanged.
|
||||
10. **The board's child calls are async (B1).** The Console runs the board in its own process. A blocking child call
|
||||
would stall every route, the event stream, `/healthz` and the tester listener, which shares the process
|
||||
(webui `cli.mjs:91`). Rule: no `spawnSync` on any path that `createBoard` reaches.
|
||||
- `scan.mjs` gains `tmuxInspectAsync` (same args, same 5 s timeout) and `scanAsync`. The sync `tmuxInspect` and
|
||||
`scan` stay for the CLI. The parse of the tmux output is one pure function that both paths call, so they cannot
|
||||
drift.
|
||||
- `serve.mjs` gains `replyToRowAsync` (same 15 s timeout, same env, same result shapes). The sync `replyToRow`
|
||||
stays for its existing tests. The reply trailer and status mapping are shared pure code.
|
||||
- The default async `exec` is `spawn` with a timeout that kills the child. A timeout or a spawn failure maps to
|
||||
`{ error }`, the same shape the sync path returns. Tests inject `exec`.
|
||||
- `index()` coalesces concurrent calls, but only onto a scan that started after the last write (rev 5, Webby N1).
|
||||
The board keeps a write counter. `markSeen()` and `reply()` bump it, then start a fresh scan and never join one.
|
||||
`index()` joins an in-flight scan only when that scan started at the current counter value. So an answer never
|
||||
omits a mark that was set before it was asked for. This coalescing is my choice to limit tmux calls under
|
||||
parallel reads; the design has no other constraint on it.
|
||||
- `conversation(url)` spawns nothing. It reads files on the event loop, as the writer did. Not changed in this row.
|
||||
|
||||
## Open points for Sage and Mos
|
||||
|
||||
- **Auth on loopback (Sage ruling 5, taken to Mos as sage-1370).** The Console has two modes. In preview mode, a named
|
||||
request needs the proxy header and proof, and the board routes inherit that. In loopback mode (no `--public-host`),
|
||||
there is no sign-in. Loopback stays read-only with no new gate in this row. Board reads stay open to any local
|
||||
process, as they are today. Reply and mark-seen cannot run there, because `--send` needs preview mode. The
|
||||
cross-uid loopback gate is row 196's. If Mos rules otherwise, Sage says so and this design changes.
|
||||
- **Row 192 overlap (Launchpad, SSO on `webui/src/serve.mjs`).** My edits touch these regions of
|
||||
`packages/webui/src/serve.mjs`:
|
||||
- lines 12-27 (`DEFAULT_BOARD`, `boardURL`)
|
||||
- line 466 (`startServer` signature, `board` option)
|
||||
- lines 473 (`upstream`)
|
||||
- lines 556-580 (board proxy block)
|
||||
- lines 598-610 (`/api/config` `board` field)
|
||||
|
||||
I do not edit the preview and proof gate (lines ~486-505) or the auth-header checks. The board branches go
|
||||
after those checks, in the same order as the bus and chat branches. Sage should sequence 192 and 198 so the
|
||||
second one rebases on the first. I will not touch the SSO code.
|
||||
- **Cutover.** The new Console code reaches the live Console through a copy and re-pin. The restart is a human
|
||||
step (`console-holds-human-authority`). No unit flag changes (decision 6). The live unit stays up until then.
|
||||
- **Sequencing (ruling 4, sage-1386).** Sage sequences 198 after 192 and after row 197 (Herb). I rebase once, onto the
|
||||
tree with 192 and 197 landed, and rerun the board route tests there. 197 replaces closed-endpoint ports with dynamic
|
||||
allocations. Its overlap with this row is `packages/webui/tests/serve.test.mjs:100` and
|
||||
`packages/webui/tests/preview.test.mjs:208`. My rewrites of those lines (the dead-board 503 change at serve.test.mjs:79
|
||||
and :105, and preview.test.mjs:219 in the N3 list) are written against 197's dynamic ports, not the old fixed ones.
|
||||
I do not edit either overlap line before 197 lands. The manifest goes to review only after the rebase.
|
||||
|
||||
## File-by-file
|
||||
|
||||
### Board package
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/control-board/src/board.mjs` | **New.** `createBoard({ specs, boardDir, seatsDir, discordDataRoot, isAlive, now, agentSend, exec, conversationReader })`. Returns `{ index(), markSeen(body), reply(body), conversation(url) }`. `index`, `markSeen` and `reply` return promises and use the async paths (decision 10). Each method is the body of a route that `createServer` runs now. No `node:http`, no listener. |
|
||||
| `packages/control-board/src/scan.mjs` | Adds `tmuxInspectAsync` and `scanAsync`, and one shared pure parse. Sync `tmuxInspect` and `scan` stay for the CLI. |
|
||||
| `packages/control-board/src/serve.mjs` | Delete `createServer` (lines 229-310), `startServer` (312-322), `page` loading (`loadPage`, 224-227), and the `node:http` import. Keep the pure exports and the sync `replyToRow`. Add `replyToRowAsync` (decision 10). Remove the `7331` default. |
|
||||
| `packages/control-board/src/cli.mjs` | `serve` exits 2 with `refused: the control board no longer serves HTTP. Use the Console: node packages/webui/src/cli.mjs serve`. The `--port` and `--host` flags are removed. `scan` is unchanged. Usage text is updated. |
|
||||
| `packages/control-board/src/page.html` | Delete. The Console serves its own `index.html`. |
|
||||
| `packages/control-board/README.md` | Remove the `serve` section (lines ~107, 127), the port default and the start instructions. Keep the reply-convention text (lines 144-151). It names no start command. |
|
||||
| `packages/control-board/package.json` | The `mosaic-board` bin keeps `scan` only. Description text updated. |
|
||||
|
||||
### Console
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/webui/src/serve.mjs` | Remove `DEFAULT_BOARD`, `boardURL` and the proxy `fetch`. `startServer({ board })` takes a context object or `null`. Board routes call `board.index()`, `board.markSeen()`, `board.reply()` or `board.conversation()` directly. If `board` is `null`, the board routes answer 503 with `{ error: 'no board configured' }`. `/api/reply` and `/api/seen` need `send !== null && named`, the same gate as the other writes. Without it they answer 403 `read-only`. `/api/config` returns `board: null` and no URL. The app reads `config.board` only for the footer text, so a `null` value needs a small change in `app.js:468`. |
|
||||
| `packages/webui/src/cli.mjs` | Remove `--board` and its usage text. Build the board context from the config, `--checkout` (or cwd, as the writer did), and the seats and board dirs. Failure to read the system config gives `board: null`, not a refusal. The Console still starts, as it does for the bus today. |
|
||||
| `packages/webui/src/public/app.js` | Line 468: keep the footer, show `Board: in-process` (or hide it). Lines 365, 468 and the 502 message text (`Board unreachable ... Check the board server`) are reworded. The text is not a contract. |
|
||||
| `packages/webui/src/public/index.html` | No change. |
|
||||
| `packages/webui/README.md` | Remove the start line (10), the `--board` line (15), the test command that lists `control-board/tests/` (766, keep only the in-process tests), and the rollback line (881). |
|
||||
| `packages/webui/systemd/mosaic-webui.service.in` | No change in the template. The `@FLAGS@` set loses `--board`. |
|
||||
| `scripts/webui-service.sh` | Remove `--board` from the usage (44, 71), the parser (110), the validation (161-162) and the flag (163). An old `--board` argument refuses with the usage error. |
|
||||
| `scripts/stack-copy.sh` | **B1.** `COPY_PATHS` (23-33) adds `packages/control-board/src` and `tools/tmux`. |
|
||||
| `scripts/stack-release.sh` | **B2.** `console_for_target` (around 664-692) drops `--board` when the target's `cli.mjs` does not parse it. The plan line names the drop. Rollback keeps the recorded flag when the target parses it. |
|
||||
| `packages/webui/README.md` | Also records (N4) that loopback board reads stay open to any local process until Mos rules. |
|
||||
| `packages/webui/src/public/bus.js` | No change. The `#/` board link is the Console's own page, not 7331. |
|
||||
|
||||
### Tests (webui)
|
||||
|
||||
Fixture plan. "Dead board" means a context whose `index()` throws, which replaces `board: 'http://127.0.0.1:9'`.
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/webui/tests/fixture.mjs` | Call `createBoard` directly. Pass the context to `startServer`. No port 0 board server. |
|
||||
| `packages/webui/tests/history-fixture.mjs` | Same as fixture.mjs. |
|
||||
| `packages/webui/tests/discord.test.mjs` | Same. Board server removed. |
|
||||
| `packages/webui/tests/relaunch.test.mjs` | Same. |
|
||||
| `packages/webui/tests/return-flow.test.mjs` | Same. |
|
||||
| `packages/webui/tests/tester-fixture.mjs` | `board: 'http://127.0.0.1:9'` becomes the dead board. |
|
||||
| 16 files with `board: 'http://127.0.0.1:9'` (bus, chat, instances-browser, limits-browser, messages, pinned-copy, preview, proof-cli, proof, routes-browser, send, settings-form, tester-cli, tester-listener, usage-browser, views) | Mechanical: dead board. Assertions that expect a 502 `Board unreachable` string are updated to the new 503 `no board configured` or the dead-board error text. Sage needs to confirm this count before the edit. |
|
||||
| `packages/webui/tests/serve.test.mjs` | Lines 17-19 (`boardURL` checks) are removed. Add the new board route tests (below). |
|
||||
| `packages/webui/tests/reads.test.mjs` | Line 188: the `board` setting becomes `null`. |
|
||||
| `packages/webui/tests/service.test.mjs` | Lines 225-394: unit `ExecStart` text loses `--board`. The `--board` refusal cases (250, 260, 394) become refusal of the old flag. |
|
||||
| Browser tests (`browser-edge`, `bus-browser`, `chat-browser`, `docs-browser`, `editor-browser`) | Fixture context only. No change to the browser assertions. |
|
||||
|
||||
### Tests (control board)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/control-board/tests/serve.test.mjs` | 32 tests use HTTP. Move the route assertions to `board.test.mjs`, which calls `createBoard` directly. Keep the `replyToRow` tests here. |
|
||||
| `packages/control-board/tests/discord.test.mjs` | Two HTTP uses (lines 123 and the `startServer` call). Call `createBoard`. |
|
||||
| `packages/control-board/tests/scan.test.mjs`, `attention*.test.mjs`, `relaunch.test.mjs` | No change. They test `scan`, not HTTP. |
|
||||
| `packages/control-board/tests/retired.test.mjs` | **New.** Three checks, all in-process, no socket. (1) `cli.mjs serve` exits 2 and prints the Console pointer. (2) The module graph has no `node:http` import and no `listen(` call in `packages/control-board/src`. (3) A grep over the start paths (`scripts/`, `*.service*`, `packages/*/README.md`, `package.json` scripts) finds no `control-board.*serve` or `7331` starter. |
|
||||
|
||||
### New tests for the acceptance items
|
||||
|
||||
| Test | Where | Assertion |
|
||||
|---|---|---|
|
||||
| Board view answers with nothing on 7331 | `packages/webui/tests/board-inprocess.test.mjs` (new) | Console with a context returns 200 on `/api/board` with a stubbed `globalThis.fetch` that throws on any call. Zero fetch calls. |
|
||||
| Every board route refuses without the Console's gate | same file | In preview mode, `/api/board`, `/api/seen`, `/api/reply`, `/api/conversations` each return 401 without the header and 403 with a foreign Host. |
|
||||
| `/api/reply` refuses without `--send` | same file | Preview mode, no `send`. POST `/api/reply` returns 403 `read-only`. `replyToRow` is never called (an injected `exec` counts calls). |
|
||||
| `/api/seen` refuses without `--send` | same file | Same shape. No mark file written. |
|
||||
| `/api/reply` runs with `--send` | same file | Positive control: with `send` and the sign-in header, the injected `exec` is called once with the expected argv. |
|
||||
| No Console proxy left | `packages/webui/tests/board-inprocess.test.mjs` | `DEFAULT_BOARD` is not exported. `boardURL` is not exported. Grep check: no `7331` in `packages/webui/src`. |
|
||||
| markSeen answer includes its own mark (N1, rev 5) | `packages/control-board/tests/board.test.mjs` (new) | Hold a scan in flight with an injected exec that resolves late. Call `index()`, then `markSeen()` before the scan resolves. The `markSeen()` answer shows the mark. A mutant that joins the pre-write scan must fail it. The same check covers `reply()`. |
|
||||
| Board does not block the Console (B1) | same file | Injected async `exec` for the tmux check resolves after 2 s. While `/api/board` is pending, `/healthz` answers within 200 ms. Mutant M14 uses an `exec` that blocks the thread for 2 s with `Atomics.wait`, and this test must kill it. |
|
||||
|
||||
## Mutants
|
||||
|
||||
Each mutant runs on a scratch copy of the tree (not the live checkout), with the new tests. The test that
|
||||
must kill it is listed. A mutant that survives means the test is weak.
|
||||
|
||||
| # | Mutant | Must be killed by |
|
||||
|---|---|---|
|
||||
| M1 | `cli.mjs serve` calls `startServer` again on 7331 | `retired.test.mjs` (1) |
|
||||
| M2 | `serve.mjs` restores `createServer` or `startServer` and exports them | `retired.test.mjs` (2) |
|
||||
| M3 | Console `DEFAULT_BOARD` is restored and the proxy `fetch` returns | `board-inprocess.test.mjs` zero-fetch case |
|
||||
| M4 | `/api/reply` skips the `send` check | `/api/reply refuses without --send` |
|
||||
| M5 | `/api/seen` skips the `send` check | `/api/seen refuses without --send` |
|
||||
| M6 | The board block runs before the preview and header gate | "every board route refuses" (401 case) |
|
||||
| M7 | A board route accepts a foreign Host | "every board route refuses" (403 case) |
|
||||
| M8 | `scripts/webui-service.sh` re-accepts `--board` | `service.test.mjs` refusal case |
|
||||
| M9 | A README or script again tells people to run `control-board serve` | `retired.test.mjs` (3) |
|
||||
| M10 | The `/api/reply` and `/api/seen` branches move below the `:558` refusal | The positive control "`/api/reply` runs with `--send`" (403 from `:558`) |
|
||||
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | Fresh-copy test in `pinned-copy.test.mjs`: `/api/board` must answer 200 |
|
||||
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs`: re-pin plan lists the drop; install argv has no `--board`. The target `cli.mjs` names `--board` only in a refusal message and has no parser branch (N2). A grep-based check keeps the flag and this test kills it. |
|
||||
| M15 | `index()` joins a scan that started before the last write (N1) | the `markSeen` answer case in `board.test.mjs` |
|
||||
| M13 | Rollback drops a recorded `--board` from a target that parses it | `stack-release.test.mjs`: rollback keeps the flag |
|
||||
| M14 | The board's tmux check or reply runs through a blocking call (`spawnSync`, or an `exec` that blocks the thread) | `board-inprocess.test.mjs` B1 case: `/healthz` must answer within 200 ms while `/api/board` is pending |
|
||||
|
||||
Mutants M11 to M13 run in the cli suite, which needs Sage's slot. M10 and M14 run in one webui test file each.
|
||||
|
||||
## Test additions (B1, B2)
|
||||
|
||||
| Test | File | Assertion |
|
||||
|---|---|---|
|
||||
| Fresh-copy Console answers the board | `packages/webui/tests/pinned-copy.test.mjs` (extend) | `stack-copy.sh` builds a copy at a fixed sha in a temp dir. Starts the Console from it on port 0. `GET /api/board` returns 200 JSON. `agent-send.sh` resolves inside the copy. |
|
||||
| Re-pin drops `--board` | `packages/cli/tests/stack-release.test.mjs` (extend) | Pointer records `--board`. Target `cli.mjs` lacks the parser branch, and names `--board` only in a refusal (the N2 fixture). The plan names the drop. The install argv has no `--board`. |
|
||||
| Rollback keeps `--board` | same | Rollback to a copy with `flag === '--board'` in its `cli.mjs` keeps the recorded flag. |
|
||||
|
||||
## Test line changes (N3)
|
||||
|
||||
Board-related lines that change, by file:
|
||||
|
||||
- `packages/webui/tests/serve.test.mjs:79`: `/api/board` on a dead board, 502 becomes 503.
|
||||
- `packages/webui/tests/serve.test.mjs:105`: 502 with the board URL in the body. Becomes 503 with no URL.
|
||||
- `packages/webui/tests/preview.test.mjs:219`: dead board, 502 becomes 503. `board.body.board` is no longer a URL.
|
||||
- `packages/webui/tests/send-browser.test.mjs:89`: a `502 board unreachable` string. Verify at edit time. Likely a bus status, which stays.
|
||||
- `packages/webui/tests/serve.test.mjs:17-19`: `boardURL` checks, removed.
|
||||
|
||||
Unchanged, not board: `bus.test.mjs:226-236`, `chat.test.mjs:171-179`, `instances.test.mjs:163-168`,
|
||||
`limits-route.test.mjs:35`, `project-file.test.mjs:128`, `routes.test.mjs:170`, `send.test.mjs:230`,
|
||||
`tester-cli.test.mjs:57`, `tester-listener.test.mjs:113`, `usage.test.mjs:24`, `board-held.test.mjs:204` (page text).
|
||||
|
||||
Dead-board fixture files (16 webui plus `tester-fixture.mjs`) match Webby's count.
|
||||
|
||||
## Gate
|
||||
|
||||
Mutant runs use single test files, which run freely. Any suite (control-board, webui, cli, every
|
||||
`scripts/test-*.sh`) needs Sage's slot.
|
||||
|
||||
Webby approves INVENTORY.md (already sent), then this DESIGN.md, then the manifest. Sage's gate runs the
|
||||
control-board and webui suites, the cli suite and every `scripts/test-*.sh`. All must be green, and mutants
|
||||
M1-M15 killed.
|
||||
|
||||
## Stop points
|
||||
|
||||
- If Mos rules that loopback board reads need a gate, this design changes. Sage tells me, and I stop and report.
|
||||
- If the Console's auth model changes under row 192, the board block in section "Console" changes with it.
|
||||
- The Console restart and unit re-render are Jason's or Mos's steps. I do not run them.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, `~/.mosaic`, or any live port.
|
||||
No request to 7331 or 7332 at any point.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Row 198 inventory: control board writer and port 7331
|
||||
|
||||
Gitea #1696. Brief: `docs/plans/2026-10-20_retire-control-board.md`. Owner Bushroot, reviewer Webby, gate owner Sage.
|
||||
Scope: tracked files at `bushroot/row198` (base `refactor`). Excludes `v1/` and other seats' `agents/*/work/` packets.
|
||||
Method: `git grep -I` for `control-board`, `control_board`, `controlboard` and `7331`, then a read of each code hit.
|
||||
|
||||
## Classes
|
||||
|
||||
- **starts**: runs `packages/control-board` `serve`, or a unit, script or default that does.
|
||||
- **imports**: loads code from `packages/control-board` at run time or test time.
|
||||
- **mentions**: text only (docs, comments, fixture strings, history).
|
||||
|
||||
## Result
|
||||
|
||||
**Live dependency found. Stop before any removal.** The Console (`packages/webui`) proxies its board reads and
|
||||
writes to the writer on 7331. The Console's board view, `/api/board`, `/api/seen`, `/api/reply` and the
|
||||
conversation routes all forward to `DEFAULT_BOARD = http://127.0.0.1:7331`. The Console has no board without
|
||||
a writer on that port. Mos decides before anything is removed.
|
||||
|
||||
Nothing in the repository runs `control-board serve` from a unit. The only starter is the manual command in
|
||||
`packages/control-board/README.md` and `packages/webui/README.md`. `ss -ltnp` showed nothing on 7331 at
|
||||
2026-10-20T19:05:09Z (brief).
|
||||
|
||||
## Hits
|
||||
|
||||
### Starts the writer, or defaults to its port
|
||||
|
||||
| File | Line | Class | What it does |
|
||||
|---|---|---|---|
|
||||
| `packages/control-board/src/cli.mjs` | 4, 14 | starts | `serve` subcommand; default `port: 7331` |
|
||||
| `packages/control-board/src/serve.mjs` | 312 | starts | `startServer` default `port = 7331` (the writer itself) |
|
||||
| `packages/control-board/README.md` | 107, 127 | starts | Tells people to run `serve` and open 7331; documents the default |
|
||||
| `packages/webui/README.md` | 10 | starts | Tells people to run `node packages/control-board/src/cli.mjs serve` |
|
||||
| `packages/webui/README.md` | 15, 881 | depends | Console reads the board at 7331; rollback uses "the unchanged board at 7331" |
|
||||
| `packages/webui/src/cli.mjs` | 14, 18 | depends | `--board` default is `DEFAULT_BOARD` (7331) |
|
||||
| `packages/webui/src/serve.mjs` | 12, 473, 561, 578, 610 | **depends (live)** | `DEFAULT_BOARD`; proxies `GET /api/board`, `GET /api/conversation(s)`, `POST /api/seen`, `POST /api/reply` upstream; returns 502 if the writer is absent |
|
||||
| `packages/webui/src/public/app.js` | 468, 365 | depends | Page reads `/api/config` board, then `/api/board` through the Console proxy |
|
||||
| `packages/webui/src/public/bus.js` | 1037, 1088, 1100 | depends | Routes `#/` to the board page; bus pages link to it |
|
||||
| `scripts/webui-service.sh` | 89-163 | depends | Renders the Console unit with `--board URL`; default comes from `serve` |
|
||||
| `packages/webui/systemd/mosaic-webui.service.in` | 29 | depends | Unit runs the Console (`webui/src/cli.mjs serve`), not the writer; flags carry `--board` |
|
||||
| `scripts/stack-copy.sh` | 88 | mentions | Checks the Console `ExecStart` string; no writer reference |
|
||||
| `scripts/stack-release.sh` | 1372 | mentions | Checks the Console command line; no writer reference |
|
||||
|
||||
### Imports code from `packages/control-board`
|
||||
|
||||
| File | Line | Class | What it imports |
|
||||
|---|---|---|---|
|
||||
| `packages/webui/tests/fixture.mjs` | 6 | imports | `startServer` from `control-board/src/serve.mjs` (test fixture for the Console) |
|
||||
| `packages/webui/tests/history-fixture.mjs` | 8 | imports | same |
|
||||
| `packages/webui/tests/discord.test.mjs` | 6 | imports | same |
|
||||
| `packages/webui/tests/relaunch.test.mjs` | 6 | imports | same |
|
||||
| `packages/webui/tests/return-flow.test.mjs` | 13 | imports | same |
|
||||
| `packages/conversation/tests/flows.test.mjs` | 15 | imports | `replyToRow` from `control-board/src/serve.mjs` |
|
||||
|
||||
The webui tests run the writer's server in-process. They do not start the writer on 7331 (the tests bind
|
||||
their own port), but they need `serve.mjs`. Any removal or refusal in `serve.mjs` breaks these tests unless
|
||||
the writer's read-only code moves first.
|
||||
|
||||
`packages/webui/src/` (non-test code) does not import `control-board`. Only the tests do.
|
||||
|
||||
### Tests that name the writer or its files
|
||||
|
||||
| File | Line | Class | Note |
|
||||
|---|---|---|---|
|
||||
| `packages/control-board/tests/serve.test.mjs` | 37, 744-767 | imports | Tests the writer: reply runner, `REPLY_TRAILER`, `-S <host>:control-board` |
|
||||
| `packages/control-board/tests/scan.test.mjs` | 47, 306, 353 | mentions | Tmp dir names and comments |
|
||||
| `packages/webui/tests/serve.test.mjs` | 18-19, 35 | mentions | `boardURL` checks on port 7331; reply trailer string |
|
||||
| `packages/webui/tests/reads.test.mjs` | 188 | mentions | Expected `board` setting value 7331 |
|
||||
| `packages/webui/tests/service.test.mjs` | 225-394 | mentions | Unit `ExecStart` text includes `--board http://127.0.0.1:7331` |
|
||||
| `packages/plugins/tests/harness.test.mjs` | 173 | mentions | Path string `packages/control-board/src/scan.mjs` |
|
||||
| `packages/ledger/tests/ledger.test.mjs` | 80, 233-327 | mentions | Sender name `control-board` as a test fixture |
|
||||
| `packages/queue/tests/fixtures/genesis-render.md` | 6-11 | mentions | Fixture text |
|
||||
| `packages/conversation/tests/reader.test.mjs` | 2 | mentions | Comment |
|
||||
| `packages/conversation/README.md` | 207 | mentions | Says the control-board suite serves the routes |
|
||||
|
||||
### Code that reads the sender name, not the writer
|
||||
|
||||
| File | Line | Class | Note |
|
||||
|---|---|---|---|
|
||||
| `packages/ledger/src/ledger.mjs` | 120 | mentions | Classifies `control-board` as sender kind `board`. No import. |
|
||||
|
||||
### Package and script metadata
|
||||
|
||||
| File | Line | Class | Note |
|
||||
|---|---|---|---|
|
||||
| `packages/control-board/package.json` | 2, 5, 9 | starts | Declares `bin: mosaic-board -> src/cli.mjs` (a `mosaic-board serve` entry point) |
|
||||
| `package.json` (root) | - | none | No script names the board or 7331 |
|
||||
| `scripts/mosaic` | 3 | mentions | Comment only |
|
||||
| `packages/seat/package.json` | 5 | mentions | Description: "the control board reads" registration |
|
||||
| `packages/seat/README.md` | 139 | mentions | Says the board reads `registration.json` |
|
||||
| `packages/plugins/README.md` | 181 | mentions | Names `scan.mjs` in an exception note |
|
||||
|
||||
### Docs (mention only)
|
||||
|
||||
- `AGENTS.md` lines 144-150: reply convention for control-board attention. Names the package README. Does
|
||||
not tell anyone to start 7331. The brief says to change it only if it does.
|
||||
- `docs/SESSIONS.md` (32), `BUILD-LOG.md` (76), `docs/plans/CURRENT.md` (8), `docs/plans/QUEUE.md` (12),
|
||||
`docs/plans/queue.json` (45), `docs/plans/2026-09-12_control-board-mvp.md` (21),
|
||||
`docs/plans/2026-10-*` and other plan pages, `docs/plans/reviews/*`, `docs/design/*`.
|
||||
- `docs/plans/CURRENT.md:171` says "Start it with `node packages/control-board/src/cli.mjs serve`". That is
|
||||
an instruction to start the writer. Update it when the writer is retired.
|
||||
- `docs/plans/2026-10-18_containerized-install-design.md:927` and `-rows.md:213` cite `serve.mjs` line numbers.
|
||||
- `docs/plans/2026-10-19_seen-click-intent.md:29` cites `serve.mjs:257` (`/api/seen`).
|
||||
- `BUILD-LOG.md` is append-only. Leave it alone.
|
||||
- `agents/bushroot/README.md`, `agents/README.md`, `docs/design/README.md`, `docs/design/IMPLEMENTING.md`:
|
||||
mention only.
|
||||
- `packages/webui/third_party/editor/PROVENANCE.json`: mention only (the vendored editor's clipboard, not the board).
|
||||
- `packages/webui/src/public/*` clipboard, board-view and CSS names: unrelated to the writer.
|
||||
|
||||
### Excluded
|
||||
|
||||
- `v1/*`: archived.
|
||||
- `agents/*/work/*`: other seats' packets (darkwing, filbert, rocko and others). Not read for this inventory.
|
||||
|
||||
## Open items for Mos
|
||||
|
||||
1. The Console's board view and proxy routes need a writer on 7331 or an equivalent read path. Retiring the
|
||||
writer means choosing a replacement first, or accepting that the board view goes dark.
|
||||
2. Four plan pages and `CURRENT.md:171` still tell people to start the writer.
|
||||
3. The webui tests and `conversation/tests/flows.test.mjs` import `serve.mjs`. Any change to it needs those
|
||||
tests moved first.
|
||||
|
||||
## Sha256
|
||||
|
||||
Recorded in the send message after commit. This file is the inventory; the commit carries it.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Duckworth
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Gander
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
|
||||
boundary row (P19 333, #1694, mos-0989).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `gander/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Herb
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
|
||||
design row (#1695, mos-0977 item 6).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Honker
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Launchpad
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Morgana
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
n=0
|
||||
for p in /proc/[0-9]*; do
|
||||
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
|
||||
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
|
||||
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
|
||||
done
|
||||
[ $n -eq 0 ] && echo CLEAR
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
|
||||
# Sequential suites, each teed to its own log; stops at the first failure.
|
||||
G=/mnt/storage/scratch/SET-ME-wt
|
||||
OUT=/mnt/storage/scratch/SET-ME-out
|
||||
cd "$G" || exit 9
|
||||
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
|
||||
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
|
||||
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
|
||||
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
|
||||
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
|
||||
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
|
||||
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
|
||||
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
|
||||
run() { local name="$1" dir="$2" cmd="$3"
|
||||
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
|
||||
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
|
||||
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
|
||||
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
|
||||
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
|
||||
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
|
||||
echo GATE_PASSED
|
||||
@@ -0,0 +1,541 @@
|
||||
# Q175 design: the Console verifies SSO itself (Vikunja #412)
|
||||
|
||||
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
|
||||
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
|
||||
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
|
||||
|
||||
## Changes from r2 (Filbert's r2 review, CHANGES)
|
||||
|
||||
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
|
||||
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
|
||||
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
|
||||
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
|
||||
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
|
||||
`/auth/login?return=<path>` (section 5 phase 1, test 24).
|
||||
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
|
||||
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
|
||||
a dummy slot, no `--send` and no `--tester-port`.
|
||||
- r3 fix (Filbert r3 approve): D1, the loader checks the slot reference's shape only, and the file checks
|
||||
run in the Console's `--sso` start; D2, the slot resolves from the config dir, and the scratch Console has
|
||||
a scratch dataRoot and an unreachable issuer (section 5).
|
||||
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
|
||||
(section 1, tests 10 and 19).
|
||||
|
||||
## Changes from r1 (Filbert's r1 review, CHANGES)
|
||||
|
||||
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
|
||||
Console prints provider ids, issuers and subject counts at start. The rehearse-strip follow-up is dropped.
|
||||
- B2: one return-path rule, `new URL(ret, publicOrigin).origin === publicOrigin` (section 1 step 7), with
|
||||
tests for `/\evil.example` and `/%09/evil.example`.
|
||||
- B3: refusals log a fixed code and the provider id, never the error or its cause (section 1).
|
||||
- B4: login records are capped at 32 (section 1 step 1), with a test.
|
||||
- B5: test 9 splits into a forged payload under a real kid and a kid missing from the JWKS, and a try/catch
|
||||
mutant joins the list. Test 2 no longer claims the signature mutant (section 9).
|
||||
- B6: the phase 2 forged-header check runs on a scratch Console, or Mos or Jason runs it on live (section 5).
|
||||
- Rulings: Q-B start degraded (section 4), Q-C only the public Host on 7332 (section 3), Q-D keep 1 h idle
|
||||
but polls don't count as activity (section 3).
|
||||
- Section 6: `startServer` throws on `tester && sso`, and `human` joins `tester` as a refused subject label.
|
||||
- The missing tests and nits N1-N5 are in sections 1, 3, 7 and 9.
|
||||
- Section 8: the human-cli statement stays as written (mos-0972). The recommendation is now Mos's ruling.
|
||||
|
||||
## The ruling this implements
|
||||
|
||||
Jason, DECISIONS Q175, verbatim: "Q1: yes. This should be a requirement for SSO usage. Whether we are using
|
||||
Pocket ID, Authentik, WorkOS, or another provider (we should support multiple), the system should use best
|
||||
practices for jwt or other methods of validation that do not rely on hacks to pass the auth." He added "Zero
|
||||
trust with verification" and confirmed five points (mos-0970):
|
||||
|
||||
1. The stack verifies every SSO login itself. It never takes identity from a proxy header or a shared secret.
|
||||
2. The Console is an OIDC relying party: discovery from the issuer URL, authorization code with PKCE, the ID
|
||||
token checked against the provider JWKS (signature, iss, aud, exp, nonce), then its own session cookie.
|
||||
3. Several providers can be configured side by side.
|
||||
4. Only asymmetric signing counts. HS256 is refused.
|
||||
5. A proxy-forwarded token such as `X-authentik-jwt` counts only through the same checks. It is optional and
|
||||
never the only way in.
|
||||
|
||||
## What exists today
|
||||
|
||||
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Row 189 (#1687, P19 #413) adds
|
||||
`stack-release.sh activate --no-send`, and Mos's ruling mos-0972 takes `--send` off the live Console until
|
||||
phase 2.
|
||||
|
||||
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
|
||||
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
|
||||
carries exactly one auth header value equal to the user (:517-521). That header value is the whole identity.
|
||||
- Requests with a loopback Host on 7332 get the unauthenticated read path (:509-512). Writes need `named` (:526-546).
|
||||
- `proofOptions` (:217) is the row 178 shared-secret header. Q175 point 1 rules it out, so it retires with the
|
||||
auth header.
|
||||
- The tester listener (`--tester-port`, row 176) binds 127.0.0.1. Its writer is fixed as
|
||||
`{ tester: true, user: 'tester' }` (:522), it allows only the instance, route, name and chat writes (:523),
|
||||
and it refuses the auth and proof headers (:486-487). 7332 refuses `x-mosaic-tester` (:485).
|
||||
- The public path is Traefik, then the Authentik proxy outpost (provider mosaic-preview, HS256, empty JWKS),
|
||||
then chisel `R:0.0.0.0:7332:127.0.0.1:7332`, then the Console. Every public request reaches the Console from
|
||||
127.0.0.1 with whatever Host the outpost forwards.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The Console becomes an OIDC relying party
|
||||
|
||||
New module `packages/webui/src/sso.mjs`, used only by the human listener (7332). Routes:
|
||||
|
||||
| Route | Method | What it does |
|
||||
|---|---|---|
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. A provider whose discovery hasn't succeeded gets 503 (section 4). |
|
||||
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
|
||||
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
|
||||
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
|
||||
|
||||
Every URL the Console hands a provider is built from the configured `publicOrigin`: `redirect_uri` is
|
||||
`publicOrigin + '/auth/callback'` and `post_logout_redirect_uri` is `publicOrigin + '/auth/signed-out'`. No
|
||||
code path reads the request's Host to build them (N2).
|
||||
|
||||
The flow per provider:
|
||||
|
||||
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
|
||||
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
|
||||
provider id, the return path and an expiry 10 minutes out in a server-side login record keyed by a random
|
||||
id. The store holds at most 32 login records; a new one evicts the oldest (B4). Set `__Host-mosaic_login` to
|
||||
that id. 303 to `authorization_endpoint` with `response_type=code`, `scope=openid profile email`,
|
||||
`code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
|
||||
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
|
||||
never from the query, which stops a mix-up between providers. Two tabs signing in at once each set the
|
||||
cookie, so the later one wins and the earlier tab's callback finds no record and gets the 403 page with a
|
||||
sign-in link (N5). That costs one retry and keeps one cookie per browser.
|
||||
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
|
||||
the `iss` parameter when the provider advertises RFC 9207.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)` returns a
|
||||
`Response`. Keep that object. `processAuthorizationCodeResponse(as, client, response, { expectedNonce:
|
||||
record.nonce, requireIdToken: true })` checks iss, aud (and azp when aud has several values), exp, iat and
|
||||
nonce, and checks `alg` against `client.id_token_signed_response_alg`. That field gets the provider's whole
|
||||
`algs` array, which the library's `checkSigningAlgorithm` accepts, so a provider listing RS256 and ES256
|
||||
takes either and nothing else (N1). Test 16 pins this.
|
||||
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
|
||||
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
|
||||
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
|
||||
`validateApplicationLevelSignature(as, response)` with the same `Response` object from step 4. It fetches
|
||||
the JWKS from the discovered `jwks_uri`, picks the key by `kty`, `kid`, `alg` and `use`, and refuses HS*
|
||||
outright. On a `kid` it doesn't have, it refetches the JWKS once if the cache is at least 60 s old, which
|
||||
covers key rotation (test 18). The call is not wrapped in a catch that continues; its throw ends the
|
||||
callback.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's `algs`, the token's `iss` equals
|
||||
the configured issuer as a raw string compare, with no URL normalising (N3; the library's discovery check
|
||||
compares normalised hrefs, so this one is stricter), and `(iss, sub)` is in the provider's `subjects` list.
|
||||
7. Create the session (section 3), then 303 to the return path. The rule (B2): resolve
|
||||
`u = new URL(ret, publicOrigin)`, and use `u.pathname + u.search` only when
|
||||
`u.origin === publicOrigin`; otherwise use `/`. A missing or unparsable `ret` gives `/`.
|
||||
|
||||
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
|
||||
step falls back to a header.
|
||||
|
||||
Logging (B3). A refusal logs one line: a fixed code from a closed list (`login-record-missing`,
|
||||
`state-mismatch`, `auth-error-response`, `grant-failed`, `id-token-invalid`, `signature-invalid`,
|
||||
`alg-refused`, `issuer-mismatch`, `subject-unknown`, `discovery-failed`, `jwks-failed`) and the provider id.
|
||||
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
|
||||
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
|
||||
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
|
||||
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
|
||||
credential. Three conditions hold:
|
||||
|
||||
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
|
||||
its own code and the provider id only.
|
||||
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
|
||||
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
|
||||
forge a log line.
|
||||
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
|
||||
|
||||
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
|
||||
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
|
||||
|
||||
### 2. Library: oauth4webapi 3.8.8, pinned exactly
|
||||
|
||||
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
|
||||
could name a defect or a dependency cost that outweighs it. I found neither.
|
||||
|
||||
- MIT, no dependencies, one ESM file (build/index.js, 99,856 bytes). Tarball sha256 `bac088aecc7c...`,
|
||||
integrity `sha512-8N28E+a/oxfXW...` (the full values go in package-lock.json).
|
||||
- `supported()` covers PS/ES/RS 256/384/512, Ed25519/EdDSA and ML-DSA. No HS*. RSA keys under 2048 bits are
|
||||
refused.
|
||||
- It does discovery with an issuer equality check (`processDiscoveryResponse(expectedIssuer, ...)`), PKCE,
|
||||
state, the RFC 9207 `iss` parameter, nonce, and JWKS caching.
|
||||
- The one gap is the default skip of the token-endpoint signature check (section 1 step 5). Calling one
|
||||
exported function closes it, so it isn't a reason to hand-roll.
|
||||
|
||||
Hand-rolling would mean writing JOSE parsing, JWK import, key selection by `kid`, the claim checks and the
|
||||
discovery checks ourselves, with the same tests and more places to get it wrong.
|
||||
|
||||
Pin: `"oauth4webapi": "3.8.8"` in the root package.json `dependencies`, next to the pi pin, with the lockfile
|
||||
entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root package.json and lock and runs
|
||||
`npm ci` there (:55-56). packages/webui/src resolves the import from the copy's root node_modules. This is the
|
||||
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
|
||||
Upgrades are reviewed commits.
|
||||
|
||||
### 3. Sessions, cookies, Host and logout
|
||||
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256 and holds no raw id, so a heap dump
|
||||
doesn't hold usable ids. Test 20 checks that the store's keys and values never contain the cookie value.
|
||||
- Cookie, exactly: `__Host-mosaic_session=<id>; Path=/; Secure; HttpOnly; SameSite=Lax`, no Domain, no
|
||||
Max-Age (a browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over
|
||||
https on this exact host. Test 21 asserts the whole Set-Cookie value, and a mutant drops `HttpOnly`.
|
||||
- Login cookie `__Host-mosaic_login`, the same attributes plus `Max-Age=600`. The login record also expires
|
||||
at 10 minutes server-side.
|
||||
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
|
||||
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
|
||||
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
|
||||
:387, :425), so cross-site writes are refused twice.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side (Q-D, Filbert's ruling). The store is in memory with a
|
||||
cap of 64 sessions (oldest goes first), so a Console restart signs everyone out. That's acceptable for one
|
||||
human and keeps sessions off disk.
|
||||
- Activity (Q-D). Only a page load (a GET outside `/api/`) or a write (a POST) refreshes `seen`. The page's
|
||||
background reads don't: `refresh` re-reads the board every 10 s (packages/webui/src/public/app.js:346) and
|
||||
the 5 s timer at :301 redraws the age label. Without this rule an open tab would never go idle. Test 13
|
||||
covers it.
|
||||
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
|
||||
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
|
||||
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
|
||||
- Host (Q-C, Filbert's ruling). Under `--sso`, 7332 serves only requests whose Host is the public host.
|
||||
Any other Host, loopback included, gets 403 `public Host required`, except `GET /healthz`. The loopback read
|
||||
path at :509-512 is gone under `--sso`. Today's readers of 127.0.0.1:7332 and where they go:
|
||||
- `scripts/webui-service.sh status` (:406), the stack-release gate (scripts/stack-release.sh:484) and the
|
||||
rehearse gate (:1369) read only `/healthz`, which stays open on loopback.
|
||||
- The webui README (:83) describes the loopback path. It changes in phase 1.
|
||||
- Agent reads of the board go to the tester listener on 7333, which already serves reads. I found no other
|
||||
code that reads 7332 on loopback. A person opening http://127.0.0.1:7332 in a local browser gets the 403
|
||||
and uses the public URL instead.
|
||||
Test 22 covers a loopback Host on `/`, `/api/board` and a write, and `/healthz` passing.
|
||||
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
|
||||
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
|
||||
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=<publicOrigin>/auth/signed-out&client_id=...`.
|
||||
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
|
||||
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
|
||||
logout is a follow-up, not phase 1.
|
||||
|
||||
### 4. Provider config: shape and location
|
||||
|
||||
Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
|
||||
`~/.config/mosaic-dev/config.json`. The reason is who can write it. The business file is writable by agents
|
||||
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
|
||||
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
|
||||
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
|
||||
(section 10).
|
||||
|
||||
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
|
||||
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
|
||||
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
|
||||
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
|
||||
|
||||
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` shape validator (the
|
||||
config checks below, except the slot file checks) runs inside the loader. The fail-closed canon holds for
|
||||
every reader. The loader checks shape only and never touches a slot file: `clientSecret` must be
|
||||
`{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}`. The file checks and the read run only in the
|
||||
Console's `--sso` start (D1), so a missing slot or a 0640 slot stops the Console, not the stack.
|
||||
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
|
||||
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
|
||||
backup first.
|
||||
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
|
||||
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
|
||||
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
|
||||
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
|
||||
- Files without the key still load, on old and new copies.
|
||||
|
||||
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
|
||||
the bad key path named, and a config without `console` loads.
|
||||
|
||||
No secret value goes in this file. The client secret is a slot reference.
|
||||
|
||||
```json
|
||||
"console": {
|
||||
"sso": {
|
||||
"publicOrigin": "https://mosaic.woltje.com",
|
||||
"providers": [
|
||||
{
|
||||
"id": "authentik",
|
||||
"issuer": "https://auth.diversecanvas.com/application/o/mosaic-stack/",
|
||||
"clientId": "<from Jason>",
|
||||
"clientSecret": { "slot": "console-sso-authentik" },
|
||||
"algs": ["RS256"],
|
||||
"subjects": [ { "sub": "<from Jason>", "name": "jason.woltje" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At start, `--sso` prints one line per provider: its id, its issuer and the number of subjects. No client id
|
||||
value beyond that, no secret, no `sub`.
|
||||
|
||||
The slot is `<configDir>/secrets/mosaic-stack/<slot>`, where `configDir` is the directory of the config file
|
||||
the loader read (`MOSAIC_CONFIG`, default `~/.config/mosaic-dev`), so a scratch config gets a scratch slot
|
||||
(D2). The Console's `--sso` start reads it. The Console refuses to start if
|
||||
the file is a symlink, isn't a regular file, isn't owned by the Console's uid, has any group or other mode
|
||||
bits (0640 refuses as well as 0644), or resolves under the repo or a stack copy. The value is never logged or
|
||||
put in a refusal message.
|
||||
|
||||
`stack-release.sh rehearse` starts its Console with `serve --port N --business B` and no `--sso`, so a
|
||||
rehearsed Console never reads `console.sso` and never uses the live redirect URI. No rehearse change is
|
||||
needed; r1's follow-up is dropped.
|
||||
|
||||
Config checks, run at start without the network. Any failure refuses the start, and the message names the
|
||||
provider and the check:
|
||||
|
||||
- `publicOrigin` is an https origin with no path, query or fragment.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
- `issuer` is https with no query or fragment.
|
||||
- `algs` is non-empty and holds only asymmetric algs from the library's list. `HS256`, any `HS*` and `none`
|
||||
refuse.
|
||||
- `subjects` is non-empty, each `sub` is 1..255 visible characters, and no `name` is `tester` or `human`.
|
||||
- `clientSecret` is `{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}` (shape only, in the loader).
|
||||
- In the Console's `--sso` start only, the slot file passes the file checks above.
|
||||
|
||||
Discovery checks, run per provider when its discovery succeeds:
|
||||
|
||||
- Discovery returns `issuer` exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`. mosaic-preview advertises HS256
|
||||
only, so it fails here.
|
||||
- `code_challenge_methods_supported` includes `S256`.
|
||||
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
|
||||
|
||||
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
|
||||
discovery document or the JWKS lands in one of two classes:
|
||||
|
||||
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
|
||||
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
|
||||
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
|
||||
|
||||
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
|
||||
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
|
||||
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
|
||||
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
|
||||
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
|
||||
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
|
||||
start, a 200 HTML page, and the recovery.
|
||||
|
||||
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
|
||||
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
|
||||
|
||||
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
|
||||
|
||||
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
|
||||
config.json. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
|
||||
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
|
||||
Nothing live changes until the re-pin.
|
||||
|
||||
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
|
||||
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
|
||||
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
|
||||
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
|
||||
`error: 'sign-in required'` as signed out, and the signed-out notice links to
|
||||
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
|
||||
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
|
||||
|
||||
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
|
||||
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
|
||||
inside the window:
|
||||
1. Back up the store, the pointer, the unit and config.json.
|
||||
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
|
||||
`console`, so the bus and seats keep reading the old config.
|
||||
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
|
||||
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
|
||||
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
|
||||
Step 4 waits until the list is clean.
|
||||
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
|
||||
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
|
||||
starts.
|
||||
5. Start the Console with `--sso --send --tester-port 7333`.
|
||||
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
|
||||
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
|
||||
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
|
||||
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
|
||||
scratch config in a scratch dir. That config has a scratch `dataRoot`, and any business it serves comes
|
||||
from the scratch config dir; neither points at live. Its dummy client secret sits in the scratch slot
|
||||
(`<scratch configDir>/secrets/mosaic-stack/<slot>`). Its issuer is an unreachable loopback host, not
|
||||
Authentik, so the scratch Console sends nothing outbound (D2, mos-0971). It comes up in the degraded
|
||||
state (Q-B), and the 401 check needs no sign-in. It has no `--send` and no `--tester-port`.
|
||||
|
||||
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
|
||||
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
|
||||
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
|
||||
reads config.json for the key's absence.
|
||||
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
|
||||
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
|
||||
with the phase 2 STATE and the phase 3 commit.
|
||||
|
||||
The Authentik proxy outpost can stay in front. It still sends `X-authentik-*` headers, and the Console never
|
||||
reads them. A test proves that a request carrying them gets nothing without a session cookie.
|
||||
|
||||
### 6. The tester port and the N3 tester identity
|
||||
|
||||
The tester listener exists so agents can test as the tester and never as Jason (mos-0870 N3). Under SSO:
|
||||
|
||||
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
|
||||
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
|
||||
goes for `X-authentik-jwt` and the old auth and proof header names.
|
||||
- `startServer` throws when its options hold both `tester` and `sso`, so the SSO module can't be mounted on
|
||||
the tester listener by a wiring mistake. A unit test calls it with both.
|
||||
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
|
||||
today. No session or header can change it.
|
||||
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
|
||||
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
|
||||
can become the human.
|
||||
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
|
||||
- `subjects` validation refuses a `name` of `tester` or `human`.
|
||||
|
||||
### 7. Point 5, the proxy-forwarded token
|
||||
|
||||
Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. If it's built later:
|
||||
|
||||
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
|
||||
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
|
||||
- Use the same alg allow list, iss, exp and subject list. `aud` is the proxy provider's client id, and the
|
||||
config check refuses it if it equals any code-flow `clientId`, so an ID token issued to the Console can't be
|
||||
replayed as a proxy token or the other way round (N4).
|
||||
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
|
||||
check until Jason gives it an RSA or EC key (Jason step 1).
|
||||
|
||||
### 8. Interim exposure until phase 2
|
||||
|
||||
Today, with `--send` live, any same-uid process on dragon-lin can connect to 127.0.0.1:7332 with
|
||||
`Host: mosaic.woltje.com`, `X-authentik-username: jason.woltje`, the matching Origin and
|
||||
`Sec-Fetch-Site: same-origin`, and send as Jason. That includes agent seats. Through the tunnel, the outpost
|
||||
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
|
||||
can reach the loopback port too (P19 333).
|
||||
|
||||
Mos ruled (mos-0972) that the Console runs without `--send` until phase 2. Reads stay up, the header path
|
||||
stops carrying writes, and Jason sends piece 1 with `mosaic talk pm` instead. Row 189 (`stack-release.sh
|
||||
activate --no-send`, #1687) makes that change through the pointer so the unit and stack-active.json stay in
|
||||
step; the restart follows its landing. Until phase 2, no agent sends a request with the auth header to 7332.
|
||||
The tests use fixture servers.
|
||||
|
||||
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
|
||||
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
|
||||
as the human without touching the Console. Q175 doesn't change that. P19 333 owns it.
|
||||
|
||||
### 9. Tests
|
||||
|
||||
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
|
||||
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a JWKS, and authorization, token
|
||||
and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an HMAC secret for the
|
||||
forgeries). It can serve two issuers at once and rotate its keys. No test reaches a real provider. Each
|
||||
refusal test asserts the status, that no `__Host-mosaic_session` was set, and that the bus send stub was
|
||||
never called. Each test also passes once unmutated, as the control.
|
||||
|
||||
Refusals Mos named:
|
||||
|
||||
1. Forged header. `X-authentik-username: jason.woltje` with `Host: mosaic.woltje.com`, the right Origin and
|
||||
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
|
||||
login on `/`.
|
||||
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback. The library's alg check refuses it
|
||||
before any signature check, so this test proves the alg rule, not the signature check. Test 9 covers that.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused. Also an `aud`
|
||||
array holding our client id and another, with no `azp`: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's 30 s clock
|
||||
tolerance): refused.
|
||||
|
||||
More refusals:
|
||||
|
||||
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
|
||||
6. Nonce mismatch or missing nonce.
|
||||
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
|
||||
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
|
||||
callback reaches the record lookup and only the take-and-delete rule refuses it.
|
||||
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
|
||||
wrong one.
|
||||
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
|
||||
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
|
||||
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
|
||||
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
|
||||
iss and sub only.
|
||||
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
|
||||
JWKS, no S256, an issuer mismatch, a subject named `tester` or `human`, `--sso` together with
|
||||
`--auth-header`, and slot files that are a symlink, mode 0644, mode 0640, or under the repo. The owner
|
||||
check has no test: making a file owned by another uid needs root, which the suite doesn't have. The
|
||||
review reads that branch instead.
|
||||
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404. `startServer` with `tester` and `sso`
|
||||
throws.
|
||||
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
|
||||
same-origin (refused, session kept). Background `/api/` GETs every 10 s for over an hour don't keep the
|
||||
session alive; a page load does.
|
||||
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
|
||||
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
|
||||
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
|
||||
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
|
||||
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
|
||||
refuses it.
|
||||
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
|
||||
PS256 even when the JWKS holds a usable PS256 key.
|
||||
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
|
||||
18. JWKS rotation: the fake OP rotates to a new key with a new kid. A sign-in after the rotation succeeds
|
||||
through the one refetch, and a token under the removed kid is refused.
|
||||
19. Logs: across every test in this file, captured log lines hold none of the tokens, codes, states, nonces,
|
||||
verifiers, the client secret or a session id that the test produced.
|
||||
20. The session store holds no raw session id (keys are sha256 values, and no value holds the cookie).
|
||||
21. Set-Cookie: the exact session and login cookie strings, including the expiry of the login cookie at
|
||||
callback.
|
||||
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
|
||||
loopback gets 200.
|
||||
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
|
||||
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
|
||||
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
|
||||
HS-only discovery at start refuses the start.
|
||||
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
|
||||
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
|
||||
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
|
||||
error. The existing opaqueredirect case still signs out.
|
||||
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
|
||||
alg, a missing `slot` field, a slot name outside the pattern, an unknown key under `console`) fails and
|
||||
names the bad key path, and no case reads a slot file (the file cases stay in test 11); a config without
|
||||
`console` loads.
|
||||
|
||||
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
|
||||
together each work with their own subjects.
|
||||
|
||||
Mutants for the gate, each killed by the named test:
|
||||
|
||||
- drop the `validateApplicationLevelSignature` call (9b);
|
||||
- wrap that call in a try/catch that continues (9b);
|
||||
- drop the `subjects` check (10);
|
||||
- accept HS* in `algs` (11);
|
||||
- take the provider from the query (15);
|
||||
- let the tester listener read cookies (12);
|
||||
- skip the login record delete (7);
|
||||
- drop `HttpOnly` from the session cookie (21);
|
||||
- refresh `seen` on every request (13);
|
||||
- build `redirect_uri` from the request Host (a test with a different Host on `/auth/login` checks the
|
||||
`redirect_uri` sent to the fake OP);
|
||||
- serve the loopback read path under `--sso` (22).
|
||||
|
||||
### 10. Jason-gated steps
|
||||
|
||||
1. Authentik mosaic-preview: give it an RSA or EC signing key or retire it, and confirm by an admin read that
|
||||
client_id `GEQ84kBbt2RG6fhYQGgZl2J9jgjw6wRJ3oSWxqZQ` belongs to it. (Ask B matched it only on scopes and
|
||||
redirect_uri.)
|
||||
2. Authentik mosaic-stack provider: register redirect URI `https://mosaic.woltje.com/auth/callback` and
|
||||
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
|
||||
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
|
||||
puts in place, per the Q173 secrets rule). Agents never see the value.
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one `subject-unknown` log line on his
|
||||
first sign-in attempt.
|
||||
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
|
||||
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
|
||||
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
|
||||
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
|
||||
the key.
|
||||
5. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
|
||||
6. The phase 2 re-pin window go.
|
||||
|
||||
## Questions for review
|
||||
|
||||
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
|
||||
|
||||
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
|
||||
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
|
||||
Mos confirms this before phase 1 starts.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Row 163 (D2): `stack-release.sh rehearse` and `bus start --no-launch`
|
||||
|
||||
Gitea #1660. Brief: docs/plans/2026-10-18_stack-release.md, "Stack rehearsal on a scratch root
|
||||
(D2)", blob b1500d96ad27. Reviewer: Filbert. Rulings: mos-0961, mos-0962, mos-0963.
|
||||
Base: 4f02486d5 (origin/refactor). D2 lands after the Part B window (P19 385), because Part B's
|
||||
window file names the stack-release.sh blob (mos-0958, mos-0959).
|
||||
|
||||
## What it does
|
||||
|
||||
`rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]` builds
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy 12>/` (0700) from a backup, runs a guard on the scratch
|
||||
config with the target copy's code, runs the migration checks when the schema differs, starts the
|
||||
target's `bus start BUSINESS --no-launch` and Console on the scratch root, gates them with D1's
|
||||
checks minus the PM's, checks that no session started, stops both, and compares the backup and the
|
||||
live config and business files with sums taken first. One PASS or FAIL line per check, then
|
||||
REHEARSAL_PASS (0) or REHEARSAL_FAIL (1). Usage exits 4. docs/TOOLS.md has the operator text.
|
||||
|
||||
`bus start --no-launch` (brief item 4): `start()` refuses `launches-disabled`, so every launch path
|
||||
spawns nothing; a session's launch request is refused and logged; `listen()` starts no thread
|
||||
ticker; `recover()` signals and ends nothing and logs what it left; the Registry is read-only, so
|
||||
sessions.json stays as an earlier host left it and `add()` refuses. The host-up line ends
|
||||
`, launches disabled`. `--pm` with it is a usage error. No unit passes it.
|
||||
|
||||
## Rulings carried
|
||||
|
||||
- mos-0961: `data-root-live` refuses a scratch dataRoot that resolves to the live data root;
|
||||
`sessions-live` refuses when a sessions.json in the scratch data root names a running pid. The run
|
||||
never deletes the file or retries. recover() under --no-launch is covered by the launcher test
|
||||
"bus start --no-launch leaves an earlier host's live session alone: no signal, no end,
|
||||
sessions.json as it was".
|
||||
- mos-0962: every tracker.* variable is stripped, business and role level; `business.diff` keeps the
|
||||
change; `tracker-configured` fails if bootConfig still sets trackers; notify.json is asserted to be
|
||||
exactly {"notifyVersion":1,"binding":null} at 0600.
|
||||
- mos-0963: every role credential reference and @sync's is moved to its own 0600 file under
|
||||
`<rehearsal>/placeholders/` holding `rehearsal-placeholder-token`; `credential-ref-live` fails for
|
||||
an env reference or a file whose real directory is not the placeholders directory. Test R9 leaves
|
||||
one role ref on a file outside placeholders and gets the FAIL; mutant K1 drops the check and R9
|
||||
fails.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
The rehearsal reaches nothing off the host:
|
||||
|
||||
- reads: the live config.json, the live business file, the pointer, the backup, the copies
|
||||
directory, the target copy's roles/, git in the canonical checkout (`copy_ok`'s merge-base);
|
||||
- writes: only `<dataRoot>/rehearsals/<stamp>/`;
|
||||
- processes: the target's bus host (`--no-launch`) and Console (`serve --port N --business B`, no
|
||||
`--send`), both with `env -i HOME PATH MOSAIC_CONFIG=<scratch config>`;
|
||||
- network: 127.0.0.1:N for /healthz, and the host's sockets under the scratch data root.
|
||||
|
||||
No model call: launches are disabled and no session starts (`no-session`). No Vikunja: tracker.*
|
||||
is stripped and `tracker-configured` checks bootConfig. No Discord: notify binding null. No Gitea:
|
||||
every credential ref is a placeholder file, and the git socket serves only bound sessions, of
|
||||
which there are none.
|
||||
|
||||
`projects.stack.root` stays at the live checkout, /mnt/storage/src/mosaic-stack. The business
|
||||
file's projects are copied unchanged. Under --no-launch nothing makes a checkout from it, because
|
||||
checkouts are made only in `start()`, which refuses.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Item 1 says to copy "the business file, roles and organization file from the live config
|
||||
directory". Roles come from the target copy's `roles/`, as the live bus reads them (rolesDir is
|
||||
`<REPO>/roles` unless MOSAIC_ROLES_DIR is set, and `env -i` leaves it unset). The live config
|
||||
directory has no organization file; bootConfig's organization entry is the config dir itself,
|
||||
which here is the scratch config dir.
|
||||
2. The business file is rewritten, not copied (mos-0962, mos-0963), and `business.diff` records
|
||||
the change.
|
||||
3. The `tracker` key stays and `tracker.sync.credentials` becomes a placeholder: the business
|
||||
schema requires both, so stripping the key (mos-0962's wording) makes the loader refuse.
|
||||
bootConfig sets trackers only with tracker.baseUrl and a project's tracker.project, and both are
|
||||
stripped; `tracker-configured` checks the result. Authority: mos-0964 (2026-10-20T14:35:30Z),
|
||||
which accepts this and replaces the "@sync still present" clause of mos-0963 item 2:
|
||||
business.mjs:197 calls checkTracker on every load, and checkTracker requires tracker and
|
||||
tracker.sync (:57, :59) and checks sync.credentials (:63). @sync stays with its ref on a
|
||||
placeholder, and `credential-ref-live` covers it with the role refs. Evidence: R10 (a tracker
|
||||
still set fails `tracker-configured`), K2 (the check dropped, R10 fails), K10 (the tracker.*
|
||||
strip dropped, R1 fails on `tracker-configured`), and K9 (the @sync swap dropped, R1 fails on `credential-ref-live`).
|
||||
4. mosaic-config already refuses a scratch config that names the live data root lexically, because
|
||||
the rehearsal directory sits inside the live data root and a dataRoot may not contain the config
|
||||
dir. The guard's `data-root-live` catches the form that reaches the live root through a link
|
||||
(R11).
|
||||
5. Files owned: the brief lists stack-release.sh, its test, TOOLS.md and the packet. Item 4's flag
|
||||
adds packages/cli/src/cli.mjs, packages/cli/src/launcher.mjs, packages/seat/src/session.mjs and
|
||||
their tests (host.test.mjs, launcher.test.mjs, session.test.mjs).
|
||||
6. The brief's "free loopback port" is an operator-given `--port`, refused when reserved (7332,
|
||||
7333, the pointer's --port and --tester-port) or in use, never replaced by another port.
|
||||
7. Brief item 3's "in the foreground": the host runs as a child of the script for the length of
|
||||
the gate, then gets SIGTERM; it is not a unit and not detached.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/cli/tests/stack-release.test.mjs, rehearse section (stand-in host, Console, migrate and
|
||||
Store in four fixture copies; real scripts/mosaic, mosaic-config, cli config and the business
|
||||
loader):
|
||||
|
||||
- R1 passes on the same schema: files, modes, placeholders, business.diff, the host's meta row in
|
||||
the scratch store and not in the backup, args logs, steps.log equal to the output, live files
|
||||
byte-identical.
|
||||
- R2 migration path 3g to 3h; R3 migrate fails.
|
||||
- R4 host fails, status fails, status names another pid, host launches, host keeps writer.lock.
|
||||
- R5 SIGTERM ignored: SIGKILL and FAIL host-stopped. R6 healthz. R7 copy-match.
|
||||
- R8 port in use, and 7332, 7333, the pointer's --port and --tester-port reserved.
|
||||
- R9 credential-ref-live (mos-0963). R10 tracker-configured (mos-0962). R11 data-root-live through
|
||||
a link and sessions-live on a running pid (mos-0961). R12 notify-off.
|
||||
- R13 old copy, backup missing, backup sums, no pointer, usage.
|
||||
|
||||
packages/cli/tests/launcher.test.mjs: recover() under --no-launch leaves an earlier host's live
|
||||
session alone; every launch is refused and logged; no thread tick (and the same host with
|
||||
launches runs it). packages/cli/tests/host.test.mjs: the host-up line, no sessions.json, --pm
|
||||
with --no-launch is usage, and no unit renders --no-launch. packages/seat/tests/session.test.mjs:
|
||||
a read-only Registry never writes and refuses add().
|
||||
|
||||
## Mutants
|
||||
|
||||
33, one per behaviour, each killed (mutants.py, mutants-run.log and mutants-rerun.log beside this file). The first run left K11
|
||||
(bus-status pid), K21 (scratch store linked to the backup), K27 (ticker under --no-launch) and
|
||||
K31 (cli drops launches); each got a test and the rerun killed all four. K21 now dies on
|
||||
FAIL backup-unchanged, the check it exists for.
|
||||
|
||||
## Round 2 (Filbert r1, comment 29445: F1-F3 changes, N1)
|
||||
|
||||
Filbert's six mutants M1-M6 survived the R tests. Each named a check that worked but that no test
|
||||
drove to FAIL. Round 2 adds those tests. stack-release.sh is unchanged (blob 0053f35d1360).
|
||||
|
||||
- F1 (M1, M2): R14. A new fixture fault, host-touches, has the host append to a file it lists.
|
||||
Three cases (the live config, the live business file, the backup's bus.sqlite) each give exactly
|
||||
one FAIL, live-config-unchanged or backup-unchanged, after the gate passed and the host stopped.
|
||||
- F2 (M3): R9 gains a pm gitea reference left on env REHEARSAL_GITEA_TOKEN, which fails
|
||||
credential-ref-live with "pm gitea is env REHEARSAL_GITEA_TOKEN" before the host starts.
|
||||
- F2 (M4): R11 gains a scratch config whose dataRoot is another existing directory ("resolves to
|
||||
X, not R/data") and one whose dataRoot is not there ("X does not exist"). Both fail
|
||||
data-root-scratch before the host starts.
|
||||
- F2 (M5): R4 gains host-launches-dir, a host that makes data/launches, which fails no-session
|
||||
with "found launches under".
|
||||
- F3 (M6): R15 sends TERM, INT and HUP to a rehearsal whose gate is still waiting on /healthz.
|
||||
Each gives FAIL signal, then console-stopped, host-stopped, host-released-store,
|
||||
backup-unchanged and live-config-unchanged all PASS, REHEARSAL_FAIL, exit 1, and the host's pid
|
||||
is gone. rehearse() gained an e.during hook that gets the child process.
|
||||
- N1: Registry.remove() wrote sessions.json under readOnly. #write() now returns at once when the
|
||||
registry is read-only, so the constructor, add() and remove() share one rule. The session test
|
||||
calls remove() on a read-only registry and checks that the file is unchanged.
|
||||
|
||||
Mutants K34-K41, one per finding and N1, are all killed (r2/mutants-run.log beside this file):
|
||||
K34 backup-unchanged always passes, K35 live-config-unchanged always passes, K36 env ref
|
||||
accepted, K37 data-root-scratch mismatch dropped, K38 missing root dropped, K39 launches dir
|
||||
ignored, K40 no INT/TERM/HUP trap, K41 read-only Registry writes.
|
||||
@@ -0,0 +1,10 @@
|
||||
04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs
|
||||
5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs
|
||||
741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 580500699 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3051.98676
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1933.965446
|
||||
pkg-cli rc=0 ℹ pass 470 ℹ fail 0 | ℹ duration_ms 337885.04401
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.587135
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33296.254589
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2624.332987
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11808.615207
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12902.241615
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2051.760841
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 247.493374
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1031.233595
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 727.865986
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5858.066276
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1147.333901
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 46548.273469
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,5 @@
|
||||
KILLED K11 bus-status pid check dropped (rc 1)
|
||||
KILLED K21 scratch store links the backup (rc 1)
|
||||
KILLED K27 ticker runs under --no-launch (rc 1)
|
||||
KILLED K31 cli --no-launch not passed to the launcher (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -0,0 +1,34 @@
|
||||
KILLED K1 credential-ref-live dropped (rc 1)
|
||||
KILLED K2 tracker-configured dropped (rc 1)
|
||||
KILLED K3 data-root-live dropped (rc 1)
|
||||
KILLED K4 sessions-live dropped (rc 1)
|
||||
KILLED K5 notify-off dropped (rc 1)
|
||||
KILLED K6 no-session dropped (rc 1)
|
||||
KILLED K7 copy-match dropped (rc 1)
|
||||
KILLED K8 role credential swap dropped (rc 1)
|
||||
KILLED K9 @sync swap dropped (rc 1)
|
||||
KILLED K10 business tracker strip dropped (rc 1)
|
||||
SURVIVED K11 bus-status pid check dropped (rc 0)
|
||||
KILLED K12 host-released-store dropped (rc 1)
|
||||
KILLED K13 SIGKILL counted as a pass (rc 1)
|
||||
KILLED K14 healthz always ok (rc 1)
|
||||
KILLED K15 port-reserved dropped (rc 1)
|
||||
KILLED K16 port-in-use dropped (rc 1)
|
||||
KILLED K17 backup-sums dropped (rc 1)
|
||||
KILLED K18 copy-without-no-launch dropped (rc 1)
|
||||
KILLED K19 migration skipped (rc 1)
|
||||
KILLED K20 migrate failure ignored (rc 1)
|
||||
SURVIVED K21 scratch store links the backup (rc 0)
|
||||
KILLED K22 scratch config keeps the live dataRoot (rc 1)
|
||||
KILLED K23 host started without --no-launch (rc 1)
|
||||
KILLED K24 start() launches under --no-launch (rc 1)
|
||||
KILLED K25 session launch request allowed under --no-launch (rc 1)
|
||||
KILLED K26 recover() ends sessions under --no-launch (rc 1)
|
||||
SURVIVED K27 ticker runs under --no-launch (rc 0)
|
||||
KILLED K28 registry writes under --no-launch (rc 1)
|
||||
KILLED K29 Registry readOnly writes anyway (rc 1)
|
||||
KILLED K30 Registry readOnly add() allowed (rc 1)
|
||||
SURVIVED K31 cli --no-launch not passed to the launcher (rc 0)
|
||||
KILLED K32 cli --pm with --no-launch allowed (rc 1)
|
||||
KILLED K33 cli host-up line drops launches disabled (rc 1)
|
||||
SOME_SURVIVED
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 163 mutants: each one drops or weakens one behaviour; the named tests must then fail.
|
||||
import hashlib, os, shutil, subprocess, sys, signal
|
||||
W = '/mnt/storage/scratch/sage-b163'
|
||||
OUT = os.environ.get('MUT_OUT', '/mnt/storage/scratch/sage-b163-gate/mutants')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
SR = 'scripts/stack-release.sh'
|
||||
T = 'packages/cli/tests/stack-release.test.mjs'
|
||||
M = [
|
||||
('K1 credential-ref-live dropped', SR, 'if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K2 tracker-configured dropped', SR, 'if(config.trackers!==undefined)fail(', 'if(false)fail(', T, '^R10 '),
|
||||
('K3 data-root-live dropped', SR, 'if(root===real(live))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K4 sessions-live dropped', SR, 'if(running.length)fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K5 notify-off dropped', SR, 'fail("notify-off",`${nf} is not {', 'void("notify-off",`${nf} is not {', T, '^R12 '),
|
||||
('K6 no-session dropped', SR, 'if [ -z "$found" ]; then r_pass no-session', 'if true; then r_pass no-session', T, '^R4 '),
|
||||
('K7 copy-match dropped', SR, ' R_GATE=copy-match\n', ' R_GATE=""; return 0\n', T, '^R7 '),
|
||||
('K8 role credential swap dropped', SR, 'strip(r?.vars);swap(name,r?.credentials)}', 'strip(r?.vars)}', T, '^R1 '),
|
||||
('K9 @sync swap dropped', SR, 'swap("@sync",b.tracker?.sync?.credentials);', '', T, '^R1 '),
|
||||
('K10 business tracker strip dropped', SR, '\nstrip(b.vars);\n', '\n', T, '^R1 '),
|
||||
('K11 bus-status pid check dropped', SR, 'String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run', 'false?`host pid ${s.host.pid} is not ${main}, the host this run', T, '^R4 '),
|
||||
('K12 host-released-store dropped', SR, 'if [ -e "$R/data/bus/writer.lock" ]; then', 'if false; then', T, '^R4 '),
|
||||
('K13 SIGKILL counted as a pass', SR, 'kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped"', 'kill -KILL "$pid" 2>/dev/null || true; r_pass "$name-stopped"', T, '^R5 '),
|
||||
('K14 healthz always ok', SR, '.then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));\' -- "$R_PORT"', '.then(()=>process.exit(0),()=>process.exit(0));\' -- "$R_PORT"', T, '^R6 '),
|
||||
('K15 port-reserved dropped', SR, '[ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved', 'true || r_abort port-reserved', T, '^R8 '),
|
||||
('K16 port-in-use dropped', SR, 'r_port_free "$R_PORT" || r_abort port-in-use', 'true || r_abort port-in-use', T, '^R8 '),
|
||||
('K17 backup-sums dropped', SR, 'sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 ||', 'true) >/dev/null 2>&1 ||', T, '^R13 '),
|
||||
('K18 copy-without-no-launch dropped', SR, 'grep -qF -- \'"--no-launch"\' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null ||', 'true ||', T, '^R13 '),
|
||||
('K19 migration skipped', SR, 'if [ "$v" = "$T_SCHEMA" ]; then r_pass migration', 'if true; then r_pass migration', T, '^R2 '),
|
||||
('K20 migrate failure ignored', SR, '[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "', 'true || r_abort migrate "', T, '^R3 '),
|
||||
('K21 scratch store links the backup', SR, 'cp "$R_BK"/bus/* "$R/data/bus/" &&', 'ln -s "$R_BK"/bus/* "$R/data/bus/" &&', T, '^R1 '),
|
||||
('K22 scratch config keeps the live dataRoot', SR, 'c.dataRoot=data;', '', T, '^R1 '),
|
||||
('K23 host started without --no-launch', SR, '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch >', '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" >', T, '^R1 '),
|
||||
('K24 start() launches under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");\n', '', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K25 session launch request allowed under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', ' if (false) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K26 recover() ends sessions under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n for (const s of leftover) log(', ' if (false) {\n for (const s of leftover) log(', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K27 ticker runs under --no-launch', 'packages/cli/src/launcher.mjs', 'if (b.launch && launches) ticker', 'if (b.launch) ticker', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K28 registry writes under --no-launch', 'packages/cli/src/launcher.mjs', 'new Registry(dataRoot, { readOnly: !launches })', 'new Registry(dataRoot)', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K29 Registry readOnly writes anyway', 'packages/seat/src/session.mjs', ' if (!readOnly) this.#write();', ' this.#write();', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K30 Registry readOnly add() allowed', 'packages/seat/src/session.mjs', ' if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K31 cli --no-launch not passed to the launcher', 'packages/cli/src/cli.mjs', 'tracker: Boolean(boot.trackers?.[businessId]), launches, ', 'tracker: Boolean(boot.trackers?.[businessId]), ', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K32 cli --pm with --no-launch allowed', 'packages/cli/src/cli.mjs', ' if (!launches && parsed.flags.has("--pm")) throw usage(', ' if (false) throw usage(', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K33 cli host-up line drops launches disabled', 'packages/cli/src/cli.mjs', '${launches ? "" : ", launches disabled"}', '', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K34 backup-unchanged always passes', SR, 'if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged', 'if true; then r_pass backup-unchanged', T, '^R14 '),
|
||||
('K35 live-config-unchanged always passes', SR, 'if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then', 'if true; then', T, '^R14 '),
|
||||
('K36 credential-ref-live accepts an env ref', SR, 'if(ref?.env!==undefined)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K37 data-root-scratch mismatch dropped', SR, 'if(root!==real(scratch))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K38 data-root-scratch missing root dropped', SR, 'if(root===null)fail("data-root-scratch"', 'if(false)fail("data-root-scratch"', T, '^R11 '),
|
||||
('K39 no-session ignores a launches dir', SR, '[ ! -e "$R/data/launches" ] || found+=" launches"', 'true || found+=" launches"', T, '^R4 '),
|
||||
('K40 no INT/TERM/HUP trap', SR, ' trap \'r_fail signal "the rehearsal was interrupted"; r_end\' INT TERM HUP\n', '', T, '^R15 '),
|
||||
('K41 read-only Registry writes', 'packages/seat/src/session.mjs', ' if (this.#readOnly) return;\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
]
|
||||
sha = lambda p: hashlib.sha256(open(p, 'rb').read()).hexdigest()
|
||||
env = dict(os.environ, TMPDIR='/mnt/storage/scratch/sb163t')
|
||||
results = []
|
||||
ONLY = set(sys.argv[1:])
|
||||
for name, f, old, new, test, pat in M:
|
||||
if ONLY and name.split()[0] not in ONLY: continue
|
||||
p = os.path.join(W, f); bak = os.path.join(OUT, os.path.basename(f) + '.orig')
|
||||
shutil.copy2(p, bak)
|
||||
if sha(bak) != sha(p): sys.exit(f'backup of {p} failed')
|
||||
src = open(p).read()
|
||||
if src.count(old) != 1: sys.exit(f'{name}: pattern count {src.count(old)} in {f}')
|
||||
open(p, 'w').write(src.replace(old, new))
|
||||
cmd = ['node', '--test'] + (['--test-name-pattern', pat] if pat else []) + [test]
|
||||
log = os.path.join(OUT, name.split()[0] + '.log')
|
||||
try:
|
||||
with open(log, 'w') as lf:
|
||||
pr = subprocess.Popen(cmd, cwd=W, env=env, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: rc = pr.wait(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pr.pid, signal.SIGKILL); pr.wait(); rc = 'timeout'
|
||||
finally:
|
||||
shutil.copy2(bak, p)
|
||||
if sha(bak) != sha(p): sys.exit(f'restore of {p} failed')
|
||||
killed = rc != 0
|
||||
results.append((name, killed, rc))
|
||||
print(f'{"KILLED" if killed else "SURVIVED"} {name} (rc {rc})', flush=True)
|
||||
print('ALL_KILLED' if all(k for _, k, _ in results) else 'SOME_SURVIVED')
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 10820.998717
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e/bus/bus.sqlitePASS copy: 2a52abd7922ec2ce073c2b481b490e984630e9ce, schema 3g
|
||||
PASS port: 127.0.0.1:38065 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2a52abd7922ec2ce073c2b481b490e984630e9ce
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data
|
||||
PASS console-stopped: pid 3546679
|
||||
PASS host-stopped: pid 3546678
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 3000.67202
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-VQM0Df/cfg/config.jsonPASS copy: bf4bac79a205e5010c48897456fc6e665a3cf9aa, schema 3g
|
||||
PASS port: 127.0.0.1:45755 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/backups/stack/20261020T000000Z-bf4bac79a205 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on bf4bac79a205e5010c48897456fc6e665a3cf9aa
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data
|
||||
PASS console-stopped: pid 3547161
|
||||
PASS host-stopped: pid 3547160
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1216.913248
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1971:1
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
AssertionError [ERR_ASSERTION]: PASS copy: f2cd6e0875411f82c798b2a541604410cc77d2c7, schema 3g
|
||||
PASS port: 127.0.0.1:41709 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/backups/stack/20261020T000000Z-f2cd6e087541 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
FAIL credential-ref-live: pm gitea is undefined, not a file in /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/placeholders
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1990:9)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1061.260755
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
AssertionError [ERR_ASSERTION]: other-rootPASS copy: 236c2e27d28711316d96143968ae477b7fa7b7e7, schema 3g
|
||||
PASS port: 127.0.0.1:37319 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/backups/stack/20261020T000000Z-236c2e27d287 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
FAIL notify-off: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root/notify/acme/notify.json is missing or not JSON
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1239.364874
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
AssertionError [ERR_ASSERTION]: no-rootPASS copy: 998d46411d6c1d3168396f7bb5932e1e200a9263, schema 3g
|
||||
PASS port: 127.0.0.1:43579 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/backups/stack/20261020T000000Z-998d46411d6c matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-v0EeC4/no-root is not the live data root
|
||||
FAIL guard: The "path" argument must be of type string. Received null
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 19506.873236
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1920:1
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
AssertionError [ERR_ASSERTION]: host-launches-dirPASS copy: 2cb8943db81ed8e914000449977627f0a9d42d00, schema 3g
|
||||
PASS port: 127.0.0.1:43729 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/backups/stack/20261020T000000Z-2cb8943db81e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2cb8943db81ed8e914000449977627f0a9d42d00
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data
|
||||
PASS console-stopped: pid 3550603
|
||||
PASS host-stopped: pid 3550602
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1924:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1749.897862
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2073:1
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
AssertionError [ERR_ASSERTION]: SIGTERMPASS copy: ba3ad437eb120f80d3a1bdc438860968dd038962, schema 3g
|
||||
PASS port: 127.0.0.1:45847 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/backups/stack/20261020T000000Z-ba3ad437eb12 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
|
||||
|
||||
null !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2079:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: null,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
✔ REQ-LIMIT-1: family substring matching is no longer exported (0.993829ms)
|
||||
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.643651ms)
|
||||
✔ sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables (0.22563ms)
|
||||
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.390202ms)
|
||||
✔ session file and launch log: 0600, the session file written once (0.890354ms)
|
||||
✔ endReason maps the runner's exit codes; a signal is killed (0.110674ms)
|
||||
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.151527ms)
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
✔ REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread (0.526184ms)
|
||||
✔ REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory (1.243195ms)
|
||||
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (0.781332ms)
|
||||
✔ a session runs in its sandbox as pid 1 of its namespace, claims, answers, and stops on mosaic stop (192.679392ms)
|
||||
ℹ tests 12
|
||||
ℹ suites 0
|
||||
ℹ pass 11
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 266.973886
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/seat/tests/session.test.mjs:137:1
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
|
||||
+ actual - expected
|
||||
|
||||
+ '{\n "sessionsVersion": 1,\n "sessions": []\n}\n'
|
||||
- '{\n' +
|
||||
- ' "sessionsVersion": 1,\n' +
|
||||
- ' "sessions": [\n' +
|
||||
- ' {\n' +
|
||||
- ' "business": "acme",\n' +
|
||||
- ' "instance": "chief",\n' +
|
||||
- ' "run": "r1",\n' +
|
||||
- ' "runnerPid": 3551219,\n' +
|
||||
- ' "runnerStartTime": "61011768"\n' +
|
||||
- ' }\n' +
|
||||
- ' ]\n' +
|
||||
- '}\n'
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/seat/tests/session.test.mjs:142:10)
|
||||
at Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
at Test.run (node:internal/test_runner/test:1402:25)
|
||||
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
at Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
at Test.run (node:internal/test_runner/test:1467:12)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: true,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: '{\n "sessionsVersion": 1,\n "sessions": []\n}\n',
|
||||
expected: '{\n "sessionsVersion": 1,\n "sessions": [\n {\n "business": "acme",\n "instance": "chief",\n "run": "r1",\n "runnerPid": 3551219,\n "runnerStartTime": "61011768"\n }\n ]\n}\n',
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
b01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs
|
||||
448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs
|
||||
cd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 2a5f3a3a0 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 2970.304395
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1953.76274
|
||||
pkg-cli rc=0 ℹ pass 472 ℹ fail 0 | ℹ duration_ms 367258.38335
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.394845
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33402.754981
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2610.108892
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11612.167166
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12928.162376
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2092.404214
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.471164
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1069.844547
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 732.731695
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5889.377731
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1159.016661
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54984.81726
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,9 @@
|
||||
KILLED K34 backup-unchanged always passes (rc 1)
|
||||
KILLED K35 live-config-unchanged always passes (rc 1)
|
||||
KILLED K36 credential-ref-live accepts an env ref (rc 1)
|
||||
KILLED K37 data-root-scratch mismatch dropped (rc 1)
|
||||
KILLED K38 data-root-scratch missing root dropped (rc 1)
|
||||
KILLED K39 no-session ignores a launches dir (rc 1)
|
||||
KILLED K40 no INT/TERM/HUP trap (rc 1)
|
||||
KILLED K41 read-only Registry writes (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -0,0 +1,7 @@
|
||||
70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md
|
||||
4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log
|
||||
ddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh
|
||||
5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md
|
||||
38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md
|
||||
fe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs
|
||||
16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh
|
||||
@@ -0,0 +1,23 @@
|
||||
b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md
|
||||
c32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md
|
||||
8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md
|
||||
ea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs
|
||||
b027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md
|
||||
bfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs
|
||||
0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs
|
||||
81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs
|
||||
b16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs
|
||||
37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs
|
||||
04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs
|
||||
2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs
|
||||
7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md
|
||||
ec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs
|
||||
f6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs
|
||||
e60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs
|
||||
3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh
|
||||
7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js
|
||||
ce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts
|
||||
f07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md
|
||||
dbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js
|
||||
76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
base 8ec03b45a dirty 11 DOCKER_HOST=unset
|
||||
unslop rc=0 | 28/28 passed
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3039.978
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 2119.713943
|
||||
pkg-cli rc=0 ℹ pass 454 ℹ fail 0 | ℹ duration_ms 294959.155129
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 590.346847
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33735.620305
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.173768
|
||||
pkg-harness rc=0 ℹ pass 142 ℹ fail 0 | ℹ duration_ms 11398.667142
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12920.093134
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2060.508354
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 245.928975
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1052.940331
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 741.129125
|
||||
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5795.650378
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1148.870662
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 55714.69432
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-b66_default
|
||||
exit=0
|
||||
2026-10-20T13:41:45Z
|
||||
@@ -0,0 +1,5 @@
|
||||
K1-note-r1 rc=1 KILLED ℹ pass 37 ℹ fail 2
|
||||
K2-reason-uncut rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K3-sent-first rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K4-no-copy-path rc=1 KILLED ℹ pass 12 ℹ fail 1
|
||||
MUTANTS_DONE
|
||||
@@ -0,0 +1,10 @@
|
||||
packages/harness/tests/runner.test.mjs rc=0 ℹ pass 39 ℹ fail 0
|
||||
packages/harness/tests/pi-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/harness/tests/claude-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-copy.test.mjs rc=0 ℹ pass 13 ℹ fail 0
|
||||
packages/webui/tests/pinned-copy.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-release.test.mjs rc=0 ℹ pass 151 ℹ fail 0
|
||||
packages/cli/tests/thread-launch.test.mjs rc=0 ℹ pass 11 ℹ fail 0
|
||||
packages/seat/tests/sandbox.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
unslop rc=0 28/28 passed
|
||||
SINGLES_DONE
|
||||
@@ -117,3 +117,82 @@ d. Before piece 1 opens, I check on the live pin which role takes a piece
|
||||
|
||||
Mos passes this to Jason when piece 1 opens, not before. Lead decisions 94,
|
||||
114 and 115 stand.
|
||||
|
||||
## 2026-10-20T12:54Z trigger (a) met; check (d), the part an agent can read
|
||||
|
||||
Trigger (a): the Part A window put 490e2771d3d0 live (pm true, schema 3i,
|
||||
proof off), checked by Mos (mos-0928). That is the first window verified
|
||||
since mos-0855, and it carries 3i, so piece 1 may open.
|
||||
|
||||
Check (d), what I can read from this seat at 12:54Z:
|
||||
- `scripts/mosaic launches list` prints one run, r34520a581318,
|
||||
mosaic-stack/chief, claude-code claude-opus-5-5, by jason, since
|
||||
2026-10-20T07:58:02Z, running.
|
||||
- The bus, the Console (7332) and that run's runner all run from
|
||||
copies/490e2771d3d0, and the bus runs `bus start mosaic-stack --pm`.
|
||||
- What I can't check: that a human message to the PM gets answered. Every
|
||||
other `scripts/mosaic` verb refuses an agent run ("this runs only from a
|
||||
human shell"), and the tester switch is Jason's. That half needs a human
|
||||
send, or Mos's Part A STATE evidence if it holds one.
|
||||
|
||||
Rule (c) still applies: Part B (P19 385) is a window, so if Jason gives J1
|
||||
while a piece is open, Part B waits for that piece to close.
|
||||
|
||||
## 2026-10-20T12:55Z mos-0960: the first message to pm is a reachability check
|
||||
|
||||
Mos accepted the correction (no date) and agreed trigger (a) is met. Check
|
||||
(d) item 3 is not settled: the Part A window (W9) tested only the tester
|
||||
thread d0ba7a66, and S1 had no human thread, so no talk or follow answer
|
||||
from pm exists on 490e2771d3d0. Mos read the live business file: launch.by
|
||||
is chief, and pm is in launch.instances with cto, assistant, coder and
|
||||
reviewer, with no threadIdleSeconds. Mos's reading of cli.mjs:345 (not a
|
||||
test) is that a talk to pm tells the chief to launch pm.
|
||||
|
||||
Ruling:
|
||||
- Jason's first message to pm is a reachability check, not a piece, like
|
||||
6b5b7aef and a8df1e12 on 10-12.
|
||||
- If pm answers, his next real Mosaic Stack request is piece 1.
|
||||
- If pm doesn't answer, I file the defect and piece 1 doesn't open.
|
||||
- Rule (c) stands. Mos recommends to Jason that J1 and Part B go before
|
||||
piece 1 if he gives both together, so the pieces run on the final Console
|
||||
auth.
|
||||
|
||||
Mos tells Jason in the morning summary.
|
||||
|
||||
## 2026-10-20T17:48Z correction: on a pinned host Jason uses plain `mosaic` (Mos mos-0968)
|
||||
|
||||
This supersedes the 10-12 line at 18:56Z, "Until row 85 lands, Jason uses
|
||||
the full path, `/mnt/storage/src/mosaic-stack/scripts/mosaic talk pm
|
||||
"..."`". Row 85 landed, and the host, bus and Console now run from the
|
||||
pinned copy 490e2771d3d0. The bus takes human commands only from the tree
|
||||
it runs from. The canonical checkout is not that tree.
|
||||
|
||||
Jason ran the full-path command Mos gave him from that line. It refused
|
||||
with human-required: "the bus accepts these commands only from the tree it
|
||||
runs from; on a pinned host run `mosaic` on PATH". ~/.npm-global/bin/mosaic
|
||||
now execs copies/490e2771d3d0/scripts/mosaic, which I read at 17:48Z, so
|
||||
Jason uses plain `mosaic talk pm "..."` or the Console. Friction is on
|
||||
Vikunja #205, comment 369.
|
||||
|
||||
Nothing else changes. The refused command never reached pm, so the
|
||||
reachability check (mos-0960) has not happened and piece 1 is not open.
|
||||
|
||||
## 2026-10-20T17:54Z S7 reachability pass (Mos mos-0969)
|
||||
|
||||
Jason ran the plain PATH command from the 17:48Z entry. His output, as Mos
|
||||
relayed it in mos-0969:
|
||||
|
||||
```
|
||||
[jwoltje@dragon-lin winA]$ mosaic talk pm "Can you hear me?"
|
||||
sent 51cebff7 to pm
|
||||
pm isn't running, chief has been told
|
||||
pm (RESULT, re 51cebff7) cff1e9ad:
|
||||
Yes, I'm getting your messages. I haven't changed anything on the tracker. What do you need?
|
||||
```
|
||||
|
||||
pm was not running when 51cebff7 arrived. The chief launched it, and pm
|
||||
answered with cff1e9ad. Under mos-0960 this is the reachability check, not
|
||||
a piece. It passed, so the "pm doesn't answer" branch does not apply and
|
||||
no defect is filed.
|
||||
|
||||
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
|
||||
|
||||
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
|
||||
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
|
||||
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
|
||||
system rules, the constitution).
|
||||
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
|
||||
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
|
||||
Everything under jason-master-restart is read only.
|
||||
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
|
||||
|
||||
## Your worktree
|
||||
|
||||
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
|
||||
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
|
||||
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
|
||||
install through a symlink or in a directory without its own package.json (npm walks up to home).
|
||||
|
||||
## Rules that bind you
|
||||
|
||||
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
|
||||
stamp in it. End each turn with a clock timestamp.
|
||||
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
|
||||
(mos-0822).
|
||||
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
|
||||
`refactor`, `main` or another seat's branch.
|
||||
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
|
||||
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
|
||||
your own row.
|
||||
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
|
||||
start, stop or restart the live Console or bus. Never read or print a credential value.
|
||||
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
|
||||
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
|
||||
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
|
||||
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
|
||||
- Two failures in one gate run means stop and report.
|
||||
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
|
||||
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
|
||||
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
|
||||
review and gate, and the chain lands in order.
|
||||
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
|
||||
Sage and your reviewer.
|
||||
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
|
||||
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
|
||||
@@ -0,0 +1,56 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) landed and pushed. Landing commit 8ecc7a239 (Sage; Filbert approve 29447), queue revs 2235-2236 (landing note and done) in 9ae8c73ac, range 4ca8d6e94..9ae8c73ac on refactor. Patch-id b8d5f8a37c24 matches candidate c6f4f4c12596. #1660 is closed with comment 29450.",
|
||||
"Sage gate passed at 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. The pushed commit is a cherry-pick of eaa0f2666 onto 7f57419b3, which adds only the Q175 design doc, and no test reads that file. Mos dropped the Part B wait when Q175 replaced Part B (mos-0971).",
|
||||
"Not live. The live stack still runs copies/490e2771d3d0. Rehearse and bus start --no-launch go live at the next re-pin, where the Q173 backup and STATE report apply.",
|
||||
] },
|
||||
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
|
||||
"Design r1 is committed: agents/sage/work/q175-sso/DESIGN.md in 7f57419b3 (blob 91e5b1b5e737), pushed in 9ae8c73ac. The Console becomes an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. It always calls validateApplicationLevelSignature, because the library skips the signature check on a token-endpoint ID token. Identity is an (iss, sub) pair, and only asymmetric algs are accepted.",
|
||||
"Filbert's r1 review asks for changes in the doc before any code (B1-B6). B1 moves the provider config to config.json, because agents have write paths into the business file. B2 closes an open-redirect hole. B3 keeps the library's error causes out of logs. B4 caps login records. B5 fixes the signature mutant test. B6 removes a live forged-header probe from the phase 2 acceptance. Their rulings: Q-B start degraded when the IdP is unreachable, Q-C yes (7332 accepts only the public Host under --sso), Q-D keep idle expiry but don't refresh it on poll GETs. r2 follows.",
|
||||
"Interim (mos-0972): the Console runs without --send until phase 2. stack-release.sh has no way to drop --send that keeps stack-active.json and the unit in step, so a small tool row comes first, through review.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) round 2 is approved by Filbert (comment 29447, queue rev 2234, committed cd2b377bb). Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest digest c6f4f4c12596. Filbert's own run: 249 of 249 single-file tests pass, and their seven mutants (M1-M6 from round 1, plus one for the read-only registry) are all killed.",
|
||||
"Not landed yet. Mos's landing order holds (mos-0965, mos-0967): the candidate moves scripts/stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If refactor moves past 4f02486d5 on any manifest path or otherwise before then, the full gate runs again on the new base before landing.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 2. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-inreview-2.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) is in review, round 2, with Filbert as reviewer (request comment 29446, queue revs 2231-2233, committed c539995cf). Round 1 got changes from Filbert (comment 29445, rev 2230): six mutants survived because no test drove those checks to fail, plus a non-blocking note that a read-only session registry still wrote on remove().",
|
||||
"Round 2 adds the tests: R14 (a host that writes the live config, business file or backup fails live-config-unchanged or backup-unchanged), R15 (TERM, INT and HUP during the gate fail signal and still stop the host and Console and run the end checks), an env-ref case in R9, other-root and missing-root cases in R11, and a launches directory case in R4. The read-only registry now never writes its file. stack-release.sh is unchanged from round 1.",
|
||||
"Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5; manifest digest c6f4f4c12596, 10 files, +1024/-13 (round 1 to round 2: 4 files, +85/-7). The gate passed on 2a5f3a3a0; mutants K34-K41 are all killed. Mos checked the manifest before the request (mos-0967). Packet: agents/sage/work/queue-163/r2/ in refactor 7af166574. Landing order is unchanged: after the Part B window closes (B7), with a new gate if the base moves.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 1. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-inreview.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) is in review, round 1, with Filbert as reviewer (request comment 29444, queue revs 2228-2229, committed def677bc7). Candidate 4be3f6aec on 580500699, base 4f02486d5; manifest digest 430212299beb, 10 files, +947/-14. It adds `stack-release.sh rehearse`, which runs the D2 rehearsal on a scratch root from a backup, and `bus start --no-launch`, a host that launches no sessions, runs no thread tick and writes no sessions.json.",
|
||||
"The gate passed on 580500699 (all package and script suites; Docker network removed, 0 containers left). Mutants K1-K33 are all killed; four survived the first run (K11, K21, K27, K31) and each got a test. Packet: agents/sage/work/queue-163/ in refactor 51f67f1f8.",
|
||||
"Mos accepted deviations 1-7 in mos-0965; deviation 3 (the scratch business keeps its tracker key, with @sync on a placeholder ref) rests on mos-0964, since business.mjs:197 refuses a file without it. Landing order (mos-0965): the candidate moves stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If the base moves before then, the gate runs again.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Sage, 2026-10-20: files the activate --no-send row (mos-0972, mos-0973) as one P19 task, owner Sage, no due date.
|
||||
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
|
||||
// the title exists. Prints no secret.
|
||||
// Usage: node file-no-send-row.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
|
||||
const T = {
|
||||
title: "stack-release.sh activate --no-send: drop the Console's --send and keep the pointer in step (Q175 interim, mos-0972)",
|
||||
description: ps(SIG, [
|
||||
"Mos ruled (mos-0972) that the Console runs without --send until Q175 phase 2 (P19 #412), because with --send live any same-uid process can forge X-authentik-username on 127.0.0.1:7332 and send as Jason.",
|
||||
"stack-release.sh has no path that drops --send and keeps ~/.mosaic-dev/state/stack-active.json and the unit in step: console_for_target (:658-687) carries the pointer's flags, check_target (:247-249) allows a same-copy activate only for the proof pair, and adopt refuses once a pointer exists. A hand reinstall makes flags_drift refuse every later activate, pm-on and rollback.",
|
||||
"The row adds activate --no-send, modeled on row 187's --no-proof. It drops --send from the pointer and the unit. A same-copy activate is allowed when the pointer holds --send. Rollback and pm-on carry the pointer as they do now. There is no re-enable flag. Brief: docs/plans/2026-10-20_activate-no-send.md. Owner Sage, reviewer Dewey, gate the cli suite plus every scripts/test-*.sh (mos-0973 go).",
|
||||
"After landing: a same-copy activate on 490e2771d3d0 with --no-send under Q173, with the plan output sent to Mos first, a backup first and a STATE report after. Then row 66 lands.",
|
||||
]),
|
||||
};
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
if ((await existing()).some((t) => t.title === T.title)) throw new Error("title exists; refusing");
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
|
||||
rec.created = { status: r.status, id: r.json?.id ?? null };
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`);
|
||||
else {
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(rec.created, { readBack: back.status, done: back.json?.done, inverse: `mark done: POST /api/v1/tasks/${r.json.id} with the whole task, done true (sage key); delete is Jason's or ops-01's` });
|
||||
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push("create read-back differs");
|
||||
}
|
||||
save();
|
||||
console.log(JSON.stringify(rec));
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"at": "2026-10-20T18:21:15.323Z",
|
||||
"project": 19,
|
||||
"created": {
|
||||
"status": 201,
|
||||
"id": 413,
|
||||
"readBack": 200,
|
||||
"done": false,
|
||||
"inverse": "mark done: POST /api/v1/tasks/413 with the whole task, done true (sage key); delete is Jason's or ops-01's"
|
||||
},
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:41:54.066Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 368,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:26:21.265Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 367,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T14:49:31.177Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 366,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"at": "2026-10-20T18:19:36.544Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 372,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 412,
|
||||
"status": 201,
|
||||
"id": 373,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Webby
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1347,3 +1347,19 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T10:36:45Z | Sage (Opus 5.5, 1ef1e4f8) | Part B window file draft 2 (mos-0957 F1, F2) and row 188 brief (#1686, P19 409/410) | draft 2 blob 55da51c0462b (148a9503d); Mos A2 recheck passed (mos-0958, ruling: B6 (1) applies to whichever chief B0's launches list shows, id recorded at B0); brief 240f21650, row 188 added and briefed (revs 2207-2208, 525990ecb), pushed 742e98976..525990ecb; Darkwing launched (sage-1278); Vikunja comments 358 (P19 385), 359 (409), 360 (410); nothing live changed
|
||||
2026-10-20T11:08:34Z | Filbert (Opus 5.5, 048f3c63) | row 188 r1 review (#1686, manifest 86aa549bad75) | approve, comment 29433, rev 2212; stack-release 151/151 on my slot run; M8-M10 and my M11 killed by T54; N1 REPO logical-path gap in stack-release.sh and webui-service.sh (mos-0959, filed after Part B)
|
||||
2026-10-20T11:14:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 188 landing (#1686, P19 409/410), N1 to P19 | landed 8fc0ee216 (patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2 unchanged), queue 31e328958 revs 2209-2214, pushed b9c1c9fe2..31e328958 with Filbert's 23a609c5c; #1686 comment 29435, closed; P19 411 (N1, after Part B per mos-0959), comments 361-363, 409/410 done; my done update blanked 409/410 descriptions, restored 11:13Z (restore-409-410-desc.mjs), script fixed
|
||||
2026-10-20T13:14:25Z | Sage (Opus 5.5, 1ef1e4f8) | row 66 build (#1559), row 42 trigger (mos-0960), row 163 brief | row 66 r1 in review: candidate 8ec03b45a (sage/row66, base 5487ed39d), manifest e68c6bce5ec4, revs 2215-2217, request 29437, Dewey and Filbert told (sage-1286-1289); gate r1 failed cli 452/453 (thread-launch.test.mjs:179, <run>/session made for the log), fixed to <run>/unslop.jsonl, gate r2 GATE_PASSED; row 42 run log: trigger (a) met, mos-0960 first pm message is a reachability check; row 163 brief owner sage, lands after Part B (6020bb43a), revs 2218-2219 (263ea8a9a)
|
||||
2026-10-20T13:18:38Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29439, rev 2221: F1 note with broken+flagged parts overflows the 32768 bus limit (probe 32934), F2 mutant M2 sent-before-send survives; 402/402 suites, M1 and M3 killed
|
||||
2026-10-20T13:52:08Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29440, rev 2222: P1 stack copies lack tools/unslop-hook (12 paths missing at candidate, 0 at base); concur F1 (note 971, sum 33004 > 32768) and F2 (sent() before send survives); N1 stale session/ comments; M1 M2 M4 M5 killed; singles 109/109, harness 140/140, plugins 111/111
|
||||
2026-10-20T13:56:35Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29442, rev 2226: F1/F2/N1 fixed, P1 copy path checked; 427/427 suites, M1-M5 killed
|
||||
2026-10-20T14:11:02Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29443, rev 2227: P1/F1/F2/N1 fixed (copy probe 0 of 16 missing; note 600, sum 32633); mutants N1-N4 killed, N5 survives (copy test skips claude-code bundle sources, non-blocking follow-up); singles 285/285, harness 142, plugins 111, cli 454, webui 269 + 1 unrelated skip
|
||||
2026-10-20T14:49:39Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) D2 rehearse + bus --no-launch: build, gate, mutants, in-review r1 | in-review, Filbert reviewer: candidate 4be3f6aec on 580500699, manifest 430212299beb, gate passed, K1-K33 killed; request comment 29444, revs 2228-2229 (def677bc7); P19 337 comment 366; lands after Part B B7 (mos-0965)
|
||||
2026-10-20T15:06:03Z | Filbert (Opus 5.5, 048f3c63) | row 163 r1 review (D2 rehearse, candidate 4be3f6aec, manifest 430212299beb) | changes, comment 29445 rev 2230: 247/247 singles pass; my mutants M1-M6 survive (F1 backup/live-config after-checks, F2 env ref, data-root-scratch, launches dir, F3 signal trap); N1 Registry.remove read-only
|
||||
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
|
||||
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
|
||||
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
|
||||
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
@@ -528,6 +528,68 @@ on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or
|
||||
`tester-required`, and queue writes stay on it ([packages/cli/README.md, One
|
||||
pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
|
||||
|
||||
#### Rehearsal on a scratch root (D2, row 163)
|
||||
|
||||
```
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
```
|
||||
|
||||
`rehearse` starts the target copy's bus host and Console on a copy of a
|
||||
backup and runs `activate`'s gate checks on them, all but the PM's, while the
|
||||
live stack keeps running. It reads the live config and a backup of the live
|
||||
store, so Jason or Mos runs it, as a window's pre-step. No development seat
|
||||
runs it against live data. It stops and starts no unit and writes no pointer
|
||||
or log line.
|
||||
|
||||
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
|
||||
absolute path. `--port` is the scratch Console's loopback port, 1024 to
|
||||
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
|
||||
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
|
||||
it never picks another port. `--timeout` (default 60 seconds) bounds the
|
||||
gate and each stop.
|
||||
|
||||
Before it makes anything, it checks the pointer, the copy (present,
|
||||
reachable from refactor, a readable schema, a bus host with `--no-launch`),
|
||||
the port and the backup's `SHA256SUMS`. Then it makes
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy's first 12>/`, mode 0700:
|
||||
|
||||
- `data/bus/` holds a copy of the backup's store files, and
|
||||
`data/notify/BUSINESS/notify.json` is `{"notifyVersion":1,"binding":null}`.
|
||||
- `config/config.json` is the live config with `dataRoot` set to `data/`.
|
||||
- `config/businesses/BUSINESS.json` is the live business file with every
|
||||
`tracker.*` variable removed and every credential reference, the roles'
|
||||
and `tracker.sync`'s, pointed at its own 0600 file under `placeholders/`
|
||||
holding `rehearsal-placeholder-token`. `business.diff` shows the changes.
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
|
||||
It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
and business files with sums it took before. The last line is
|
||||
`REHEARSAL_PASS` (exit 0) or `REHEARSAL_FAIL` (exit 1), then the directory.
|
||||
Usage exits 4. Nothing prunes rehearsal directories.
|
||||
|
||||
`bus start --no-launch` refuses every launch with `launches-disabled`,
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -280,7 +280,7 @@ shows only at start is found with the live stack stopped.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: set when the row is briefed.
|
||||
- Owner: sage (row 163, briefed 2026-10-20).
|
||||
- Reviewer: filbert.
|
||||
|
||||
### Files owned
|
||||
@@ -347,3 +347,8 @@ Mutants: one per behavior above.
|
||||
|
||||
As D1. D2 lands after D1, because both edit `stack-release.sh` and its
|
||||
test file.
|
||||
|
||||
D2 also lands after the Part B window (P19 385) has run. The Part B window
|
||||
file names the `stack-release.sh` blob, and any change to that blob means
|
||||
Mos checks the window again (mos-0958, mos-0959). The build and review can
|
||||
go ahead before then.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
|
||||
`packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
|
||||
## Why
|
||||
|
||||
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:822).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
## The change
|
||||
|
||||
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
|
||||
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
|
||||
`--tester-port`, the proof pair and `--no-proof`.
|
||||
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
|
||||
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
|
||||
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
|
||||
`--no-send` still refuses `already-active`.
|
||||
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
|
||||
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
|
||||
`--send` goes on unchanged, or `send: off`.
|
||||
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
|
||||
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
|
||||
flags in its own row.
|
||||
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs)
|
||||
|
||||
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
|
||||
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
|
||||
line. The log line has from = to = B, and the stopped store was backed up.
|
||||
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
|
||||
gone.
|
||||
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
|
||||
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
|
||||
the unit.
|
||||
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
|
||||
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
|
||||
names and keeps the rest.
|
||||
|
||||
## Mutants (each must fail at least one test)
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
|
||||
`P_CONSOLE`).
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the arg parser accepts `--no-send` for rollback.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
|
||||
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
|
||||
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
|
||||
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
|
||||
owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
|
||||
code.
|
||||
|
||||
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
|
||||
|
||||
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
|
||||
`broker.mjs:1968`). No user verb launches an arbitrary agent.
|
||||
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
|
||||
is no regenerate, no delete and no Console launch.
|
||||
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
|
||||
only, no delete.
|
||||
- Human-only membership acts as a veto today, but there is no per-agent veto list.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
|
||||
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
|
||||
stays write-once). Each one is a human verb on the CLI and a Console action.
|
||||
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
|
||||
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
|
||||
the broker enforces it.
|
||||
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
|
||||
safely.
|
||||
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
|
||||
states the hook that row 193 fills.
|
||||
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
|
||||
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
|
||||
row adds verbs, not new role authority. Any role file change is called out for review.
|
||||
- The build may chain on rows 190, 191 and 193 candidates (Q177).
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
|
||||
|
||||
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
|
||||
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
|
||||
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
|
||||
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
|
||||
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
|
||||
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
|
||||
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
|
||||
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
|
||||
this row or nothing.
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
|
||||
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
|
||||
`agents/darkwing/work/queue-190b/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
|
||||
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
|
||||
through the existing git.mosaicstack.dev remotes, books repo only.
|
||||
|
||||
## Provision (blank until Jason answers #419)
|
||||
|
||||
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
|
||||
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
|
||||
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
|
||||
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
|
||||
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
|
||||
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
|
||||
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
|
||||
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
|
||||
or a new provider account, flag it for Jason and don't pick one (mos-1008).
|
||||
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
|
||||
can't read or push any other repository.
|
||||
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
|
||||
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
|
||||
holds it.
|
||||
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
|
||||
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
|
||||
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
|
||||
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
|
||||
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
|
||||
for the hub host
|
||||
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
|
||||
host all fail
|
||||
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
|
||||
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
|
||||
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
|
||||
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
|
||||
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
|
||||
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
|
||||
call from every token the factory's root can read. No credential value is printed in that proof.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
|
||||
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
|
||||
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
|
||||
mos-0995. Nothing on it changes, and her Hermes bots keep running.
|
||||
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
|
||||
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
|
||||
the acceptance script proves it.
|
||||
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
|
||||
approves, never as a test or an anonymous probe (Q175).
|
||||
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
|
||||
host are a window with a backup first (where there is state) and a STATE report after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
|
||||
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Brief: the human boundary across uids and humans (P19 333)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
|
||||
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
|
||||
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
|
||||
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
|
||||
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## The problem, as filed and as it now matters
|
||||
|
||||
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
|
||||
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
|
||||
(mos-0787, row 161). Two things change that:
|
||||
|
||||
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
|
||||
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
|
||||
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
|
||||
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
|
||||
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
|
||||
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
|
||||
|
||||
## What already settles part of it
|
||||
|
||||
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
|
||||
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
|
||||
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
|
||||
only after the phase 2 window.
|
||||
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
|
||||
read-only. It settles nothing.
|
||||
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
|
||||
its directory and socket modes, which this row checks and tests rather than assumes.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
|
||||
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
|
||||
reach it today from the same uid and from another uid, with and without `--sso`.
|
||||
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
|
||||
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
|
||||
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
|
||||
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
|
||||
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
|
||||
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
|
||||
a same-uid process after this row. Say it plainly where a residual limit remains.
|
||||
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
|
||||
bus verifies) and with federation R3. One mechanism, not three.
|
||||
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
|
||||
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
|
||||
closed.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
|
||||
reaches a local port or socket.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
|
||||
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
|
||||
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Brief: many humans per business, human roles, private and shared threads
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
|
||||
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
|
||||
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
|
||||
commit.
|
||||
|
||||
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
|
||||
any code.
|
||||
|
||||
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
|
||||
(`packages/bus/src/broker.mjs:532-538`).
|
||||
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
|
||||
or shared.
|
||||
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
|
||||
have no roles.
|
||||
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
|
||||
as the human. That limit is real and the design must say how several humans change it.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
|
||||
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
|
||||
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
|
||||
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
|
||||
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
|
||||
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
|
||||
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
|
||||
the owner (Case 1).
|
||||
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
|
||||
named humans or all members). Schema change, migration of existing rows (all become the current human's),
|
||||
and every read path that must filter.
|
||||
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
|
||||
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
|
||||
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
|
||||
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
|
||||
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
|
||||
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
|
||||
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
|
||||
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
|
||||
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
|
||||
it.
|
||||
6. Migration and rollback. Existing businesses keep working with their one human as owner.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Brief: one kill switch for the bus, every run, Discord and new launches
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "Kill switch is available to
|
||||
stop all processing immediately"). Gitea #1689. T1 item 4. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor
|
||||
at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/rocko/work/queue-191/DESIGN.md`, which Dewey reviews before
|
||||
any code. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
One human command and one Console button stop all processing at once and keep it stopped until the human
|
||||
clears it:
|
||||
|
||||
- The bus takes no new work. Agent verbs that start or continue work refuse with a fixed code while the
|
||||
switch is on. Reads and the human's own verbs, including the clear, still work.
|
||||
- Every running managed session and task run is stopped (SIGTERM, then SIGKILL after a bounded wait), and
|
||||
each stop is recorded as an event.
|
||||
- Discord stops (its existing STOP file and `stop` verb) and can't restart while the switch is on.
|
||||
- New launches refuse: the launcher, chief hire, `run-task.sh`, and any other path that starts an agent.
|
||||
- The switch survives a bus restart and a host reboot (it lives in the data root, not in memory).
|
||||
- Turning it on and clearing it are human-only verbs, through the bus human proof, and each leaves one
|
||||
event with who and when.
|
||||
|
||||
## What exists today (GAP-MAP.md G10, measured at 9ae8c73ac)
|
||||
|
||||
These are pieces, with no single switch over them. The design inventories every path that starts or
|
||||
continues work and says how each one checks the switch:
|
||||
|
||||
- `launch.revoke` and `launch.restore` in `packages/bus/src/broker.mjs` (around :2087), human-only.
|
||||
- The `mosaic launches off|on` verbs in `packages/cli/src/cli.mjs` (around :456-533) and
|
||||
`packages/cli/src/launcher.mjs` (around :769).
|
||||
- The Discord STOP file (`packages/discord/src/cli.mjs:18-27`).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Where the switch state lives, its format, and how each process reads it (the broker checks on every
|
||||
work verb; the launcher and Discord check before start). The race: a launch already past its check when
|
||||
the switch goes on must still be stopped.
|
||||
2. The CLI verb names (for example `mosaic halt` and `mosaic resume`) and the Console button. The button
|
||||
is a human write, so it goes through the same writer and same-origin checks as other Console writes, and
|
||||
it works under the Q175 SSO session (#412) once phase 2 lands. Until then, test it with fixture servers
|
||||
only.
|
||||
3. Per instance. Row 190 adds several instances per host. The switch is per instance, so it stops one
|
||||
instance and never another. The design names how it finds the instance's data root. This row may build
|
||||
on the row 190 candidate (Q177 merge-chaining).
|
||||
4. What a stopped run's record says, so run evidence stays write-once and honest.
|
||||
|
||||
## Tests
|
||||
|
||||
Each part of the goal gets a test: work verbs refuse, a running stand-in session and run are stopped,
|
||||
Discord refuses to start, every launch path refuses, the switch survives a restart, an agent can't set or
|
||||
clear it, and the clear lets work resume. Mutants: drop the check from one launch path, let an agent clear
|
||||
the switch, keep the switch in memory only, skip the SIGKILL after the wait. Each one must fail a test.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, the live units, a real Discord bot or a slot file. Use the stand-in
|
||||
units, scratch data roots and fixture servers the suites already build.
|
||||
- Rocko has no git credentials. Sage commits the candidate and runs the queue moves Rocko can't.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves DESIGN.md, then the manifest. The cli suite, the bus, discord and webui package suites and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Brief: several Mosaic Stack instances on one host
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176-Q178, Vikunja #414). Gitea #1688. T1 item 1. Owner
|
||||
Darkwing, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit. This row blocks both MVP
|
||||
cases: Case 1 (Jason's software factory) and Case 2 (Melanie's creative writing hub) run as two instances
|
||||
on one homelab host, beside the current mosaic-stack test instance.
|
||||
|
||||
Design first. The first deliverable is `agents/darkwing/work/queue-190/DESIGN.md`. Filbert reviews it
|
||||
before any code, as Q175 did. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
|
||||
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
|
||||
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. No instance's
|
||||
commands, units or tools address another instance's state by default or by mistake. On one uid this is
|
||||
addressing only, not a security boundary: a same-uid process can still read another instance's files.
|
||||
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
|
||||
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
|
||||
|
||||
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
The design starts with a full inventory. Grep every script and package, not only these:
|
||||
|
||||
- `packages/cli/systemd/mosaic-bus.service.in`: `mosaic-bus@<business>` sets no `MOSAIC_CONFIG` and reads the
|
||||
fixed `EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`.
|
||||
- `packages/webui/systemd/mosaic-webui.service.in` and `scripts/webui-service.sh`: one `mosaic-webui.service`.
|
||||
- `packages/discord/systemd/[email protected]` and `scripts/discord-service.sh`.
|
||||
- `scripts/stack-copy.sh:8` hardcodes `BUSINESS=mosaic-stack`, and `:95` reads `mosaic-webui.service`.
|
||||
- `scripts/stack-release.sh`: unit names, the Console port and the tester port default, `COPIES` (:28), the
|
||||
pointer under `$DATA/state` (:1474), and `rehearse`.
|
||||
- `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/reset.sh`, `scripts/land-work.sh`,
|
||||
`packages/webui/src/cli.mjs`, `packages/business/src/{business,vars}.mjs`, and
|
||||
`packages/conversation/src/guard.mjs` each name the default config path or data root.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Instance identity. How a name maps to its config file and how every tool picks the instance (an
|
||||
`--instance NAME` flag, `MOSAIC_CONFIG`, or both), and what happens with neither (the current default,
|
||||
unchanged). Name rules, and refusal of a name that collides.
|
||||
2. Config canon. AGENTS.md invariant 2 says `~/.config/mosaic-dev/config.json` is the sole system config,
|
||||
created only by `scripts/bootstrap.sh`. Several instances mean several config files. The design proposes
|
||||
the exact wording change and how bootstrap creates an instance config without overwriting one. Mos rules
|
||||
on the canon change before the build.
|
||||
3. Unit names and templates. The bus, Console and Discord units per instance, how `MOSAIC_CONFIG` and the
|
||||
Claude env file reach each unit, and how the current unit names stay valid for the default instance.
|
||||
4. Ports. Each instance's Console and tester ports, with a refusal when two instances claim the same port.
|
||||
5. Stack copies and the pointer. Whether copies are shared between instances (read-only trees, so sharing
|
||||
may be fine) and how the pointer, activation log, backups and `flags_drift` stay per instance.
|
||||
`stack-release.sh` and `stack-copy.sh` take the instance.
|
||||
6. Secrets. The per-instance Claude session env file and the secrets slot root, resolved from the instance's
|
||||
config dir (the Q175 design resolves its slot the same way: `<configDir>/secrets/mosaic-stack/<slot>`).
|
||||
7. Isolation proof. What the tests show: two instances started side by side on stand-in units and scratch
|
||||
roots, each reads only its own config and data root, a command aimed at one never touches the other, and
|
||||
the default instance's paths and unit names are byte-identical to today.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. Use the stand-in
|
||||
systemctl, units and scratch roots that `packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
- Standing up a real second instance on dragon-lin is a later window under Q173, with a backup first and a
|
||||
STATE report after. Exposing a new Console through the tunnel or Traefik changes who can reach it from
|
||||
outside, so it waits for Jason. SSO (Q175, #412) comes first for any instance a second user can reach.
|
||||
- Merge-chaining (Q177): rows that need instances (human users, kill switch, lifecycle) may build on this
|
||||
row's candidate. This row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Filbert approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Brief: retire the control board writer on 7331
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
|
||||
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
## Why
|
||||
|
||||
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
|
||||
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
|
||||
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
|
||||
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
|
||||
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
|
||||
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
|
||||
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
|
||||
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
|
||||
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
|
||||
Mos before anything is removed.
|
||||
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
|
||||
message that points to the Console. Keep any read-only code another package imports, and say which.
|
||||
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
|
||||
is a separate matter. Change it only if it tells someone to start 7331.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
|
||||
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
|
||||
back and the test kills it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
|
||||
- No request to any live port.
|
||||
- Single test files run freely. Suites need Sage's slot.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves INVENTORY.md, then the manifest. The control-board and webui package suites, the cli suite and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Brief: a second gate lane, two full gates at once on dragon-lin
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
|
||||
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
|
||||
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/herb/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
|
||||
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
|
||||
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
|
||||
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
|
||||
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
|
||||
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
|
||||
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
|
||||
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
|
||||
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
|
||||
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
|
||||
bound, or a lane rule that keeps those suites apart).
|
||||
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
|
||||
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
|
||||
4. The gate template takes a lane id and isolates everything from point 1 by it.
|
||||
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
|
||||
wall time of each against a single-lane run. Any flake found is named with its cause.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
|
||||
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
|
||||
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
|
||||
goes to its owner.
|
||||
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, both singly and in the two-lane proof.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
|
||||
|
||||
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
|
||||
Launchpad, reviewer Webby (mos-0981), gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
|
||||
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
|
||||
the code shows the design is wrong somewhere, stop and tell Sage, Webby and Filbert before building around it.
|
||||
|
||||
## Scope (phase 1 only, design section 5 item 1)
|
||||
|
||||
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
|
||||
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
|
||||
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
|
||||
(design sections 1 and 3).
|
||||
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
|
||||
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
|
||||
the config dir (section 4, D1, D2).
|
||||
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
|
||||
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
|
||||
- The tester listener rules (section 6).
|
||||
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
|
||||
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
|
||||
- Logging as the design says, including the Q-E `subject-unknown` line.
|
||||
|
||||
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
|
||||
(section 7), and any change to the live Console.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
|
||||
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
|
||||
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
|
||||
slot file. Test 25 goes in `scripts/test-config.sh`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
|
||||
design section 8).
|
||||
- No credential value is read, printed or logged.
|
||||
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
|
||||
walk up to home.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. Filbert approved the design and answers design questions. The webui and cli suites, `scripts/test-config.sh` and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
+6
-3
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not e
|
||||
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
|
||||
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
|
||||
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | briefed | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 2, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
|
||||
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
|
||||
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
|
||||
@@ -199,7 +199,7 @@ Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not e
|
||||
| 160 | I5 managed sessions as containers | researcher; reviewers dewey, filbert | #1655 | queued | 158 done | Filbert and Dewey approve on #1655 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I5 managed sessions as containers | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet; the goal order sets priority |
|
||||
| 161 | I6 install mode, the pi worker's move and the documented limits | dewey; reviewers darkwing, filbert | #1656 | queued | 159 done, 160 done | Filbert and Darkwing approve on #1656 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I6 install mode, the pi worker's move and the documented limits | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet. B2 is decision 148. The documented-limits section waits on Jason's answer to P19 333 (mos-0657) |
|
||||
| 162 | D1: health-gated stack activate and rollback (stack-release.sh) | darkwing; reviewers filbert | #1659 | done | — | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack activate and rollback, health-gated (D1) | P19 task 336 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | queued | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | done | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971). |
|
||||
| 164 | Channel plug-in kind: cli and webui as its first modules (Q156 item 4, decision 149) | rocko; reviewers dewey | #1661 | done | 129 done | Dewey approves the candidate manifest; plugins, cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_channel-kind.md` § The channel kind, with cli and webui as its first modules | Design packet first, Sage sends it to Mos (human proof adjacent). Nothing goes between row 129 and this row (Mos mos-0726). |
|
||||
| 165 | The human adds and changes role instances: a CLI command and a Console view (P19 332) | darkwing; reviewers dewey, filbert | #1662 | done | 155 done | Mos relays Jason's confirmation before briefed; Dewey and Filbert approve on #1662 naming the candidate manifest; Mos's decision 123 go on the public/ diff; business, cli and webui suites and every scripts/test-*.sh green on Sage's gate (sage) (sage) | `docs/plans/2026-10-18_role-instance-verb.md` § The human adds and changes role instances | Briefed after 155 (b2f57ca70); hold lifted by mos-0805. Chief of staff stays out (mos-0787). sage-0918: lead approved file expansion (bus broker/runtime, webui cli.mjs, scripts/mosaic, commented channel-proof pin bump); new verbs human-only. Credentials/tracker stay out; second-reviewer gap with Mos (sage-0920). Acceptance 314 keeps the hand edit until this lands. Mos's decision 123 go on public/. |
|
||||
| 166 | stack-release.sh hardening after D1: drop-in precheck, is-active race, copy check against the target, quoted Environment= (row 162 follow-up) | darkwing; reviewers filbert | #1663 | done | 162 done | Filbert approves the candidate manifest; cli suite and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release-hardening.md` § Brief: stack-release.sh hardening after D1 | Landed 4b738e2da on 64922106d (pushed). It is Darkwing r2 build.patch, manifest 4fc0613f OK, patch-id 7a4fdc3bf53b equal to build.patch. Sage gate GATE_PASSED: 15 pkg suites 2399/0 (cli 364), 10 script suites green. Mos R1 (mos-0867): no quotes or drop-ins on live units. stack-release.sh blob now ed5b41d4677e; Window B P3 reruns on it (mos-0867 R2). Row 169 rebases onto 4b738e2da. |
|
||||
@@ -225,6 +225,9 @@ Generated from `docs/plans/queue.json` revision 2214 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1212
-11
File diff suppressed because it is too large.
Load diff
@@ -57,7 +57,7 @@ export const USAGE = `usage:
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
mosaic bus start <business> [--pm]
|
||||
mosaic bus start <business> [--pm | --no-launch]
|
||||
mosaic bus stop
|
||||
mosaic bus status [--json]`;
|
||||
|
||||
@@ -482,8 +482,11 @@ async function launches(rest, io, deps) {
|
||||
async function bus(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "start") {
|
||||
const parsed = parse(args, { flags: ["--pm"] });
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
|
||||
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
|
||||
const launches = !parsed.flags.has("--no-launch");
|
||||
if (!launches && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
const businessId = parsed.positional[0];
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
|
||||
@@ -500,9 +503,9 @@ async function bus(rest, io, deps) {
|
||||
const binding = readNotifyConfig(system.dataRoot, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
|
||||
try {
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
|
||||
@@ -153,7 +153,11 @@ function call(name, what, fn) {
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
@@ -162,6 +166,7 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
launches = true,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
@@ -187,7 +192,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot);
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
const children = new Map();
|
||||
let chain = Promise.resolve();
|
||||
let closed = false;
|
||||
@@ -323,6 +328,8 @@ export function createLauncher({
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// 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.
|
||||
let store = null;
|
||||
@@ -627,6 +634,11 @@ export function createLauncher({
|
||||
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
|
||||
return { ok: false, error: "not-a-role" };
|
||||
}
|
||||
if (!launches) {
|
||||
await host.op({ op: "refuse", cap, code: "launches-disabled" }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
|
||||
return { ok: false, error: "launches-disabled" };
|
||||
}
|
||||
let checked;
|
||||
try {
|
||||
if (typeof instance !== "string") throw new Refusal("invalid-request");
|
||||
@@ -675,6 +687,13 @@ export function createLauncher({
|
||||
// This broker never bound those runs, so each is bound again first with
|
||||
// its identical launch record, which the broker accepts.
|
||||
async function recover() {
|
||||
// Row 163 (mos-0961): a --no-launch host never signals or ends an earlier host's session. Its
|
||||
// data root may hold a sessions.json that names live processes, so it only says what it left.
|
||||
if (!launches) {
|
||||
for (const s of leftover) log(`run ${s.run} (${s.instance}) from an earlier host: left as it is, this host was started with --no-launch`);
|
||||
leftover = [];
|
||||
return;
|
||||
}
|
||||
for (const s of leftover) {
|
||||
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
|
||||
try {
|
||||
@@ -744,7 +763,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -811,6 +811,29 @@ test("bus start runs until bus stop; status reports it while it runs", async (t)
|
||||
assert.match(again.stdout, /no host runs/);
|
||||
});
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal's host.
|
||||
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
notifyConfig(f.dataRoot, "acme", null);
|
||||
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
|
||||
assert.equal(both.status, 4, both.stderr);
|
||||
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
|
||||
let out = "";
|
||||
child.stdout.on("data", (b) => (out += b));
|
||||
child.stderr.on("data", (b) => (out += b));
|
||||
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
|
||||
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
|
||||
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
|
||||
const exit = once(child, "exit");
|
||||
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
|
||||
assert.equal(stop.status, 0, stop.stderr);
|
||||
assert.equal((await exit)[0], 0, out);
|
||||
});
|
||||
|
||||
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
|
||||
const dir = tmp(t);
|
||||
const script = join(REPO, "scripts", "bus-service.sh");
|
||||
@@ -818,6 +841,8 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
|
||||
assert.equal(render.status, 0, render.stderr);
|
||||
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
|
||||
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
|
||||
// Row 163: --no-launch is for the D2 rehearsal's own host only; no unit starts a bus with it.
|
||||
assert.doesNotMatch(render.stdout, /--no-launch/);
|
||||
// One session credential file, optional so Pi sessions start without it.
|
||||
// The README's paragraph names the same file; nothing else does.
|
||||
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
|
||||
|
||||
@@ -15,11 +15,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { childrenOf } from "../../seat/src/proc.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -901,6 +902,56 @@ for (const exited of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal host. A live bwrap process the test owns stands in for an
|
||||
// earlier host's session; its outer bwrap is what recover() would kill, and its child stands in
|
||||
// for the runner that close() would signal.
|
||||
test("bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const { f } = given;
|
||||
const box = spawn(BWRAP, ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--die-with-parent", "sleep", "60"], { stdio: "ignore" });
|
||||
t.after(() => box.kill("SIGKILL"));
|
||||
const pid = box.pid;
|
||||
const startTime = await until(() => startTimeOf(pid));
|
||||
const runnerPid = await until(() => childrenOf(pid)[0]);
|
||||
const runnerStartTime = await until(() => startTimeOf(runnerPid));
|
||||
assert.equal(cmdlineOf(pid)[0], BWRAP, "recover() would take it for a session");
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "chief", run: "run-left", harness: "pi", pid, startTime, runnerPid, runnerStartTime });
|
||||
const before = readFileSync(sessionsFile(f.dataRoot), "utf8");
|
||||
|
||||
const s = await setup(t, undefined, given, { launches: false });
|
||||
assert.ok(s.logs.includes("run run-left (chief) from an earlier host: left as it is, this host was started with --no-launch"), s.logs.join("\n"));
|
||||
assert.equal(startTimeOf(pid), startTime, "listen() sent no signal");
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
assert.deepEqual((await s.client.call("trail", { subject: "run-left" })).filter((r) => r.kind === "session.ended"), []);
|
||||
assert.equal(await s.close(), 0);
|
||||
assert.equal(startTimeOf(pid), startTime, "close() sent no signal either");
|
||||
assert.equal(startTimeOf(runnerPid), runnerStartTime);
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
});
|
||||
|
||||
test("bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned", { skip, timeout: 30000 }, async (t) => {
|
||||
const s = await setup(t, undefined, undefined, { launches: false });
|
||||
await assert.rejects(s.launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: launches-disabled/.test(e.message));
|
||||
const sock = launchSocketPath(s.f.dataRoot);
|
||||
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: s.cto.cap, instance: "coder" })}\n`), { ok: false, error: "launches-disabled" });
|
||||
assert.deepEqual(log(s.f).map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "launches-disabled"], ["refused", "coder", "launches-disabled"]]);
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
const runs = runsDir(s.f.dataRoot, "acme");
|
||||
assert.deepEqual(existsSync(runs) ? readdirSync(runs) : [], [], "no run directory");
|
||||
assert.equal(await s.close(), 0);
|
||||
});
|
||||
|
||||
test("bus start --no-launch starts no thread tick; the same host with launches runs it", { skip, timeout: 30000 }, async (t) => {
|
||||
for (const launches of [false, true]) {
|
||||
const asked = [];
|
||||
const wrap = (host) => Object.create(host, { op: { value: async (m) => (["threadsState", "landHolders"].includes(m.op) && asked.push(m.op), host.op(m)) } });
|
||||
const s = await setup(t, undefined, undefined, { launches, threadTickMs: 50, wrap });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
assert.equal(asked.length > 0, launches, `launches ${launches}: ${asked.join(", ")}`);
|
||||
assert.equal(await s.close(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const file = sessionsFile(given.f.dataRoot);
|
||||
|
||||
@@ -1705,3 +1705,382 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
await noLeak(e);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
|
||||
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
|
||||
|
||||
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
|
||||
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
|
||||
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
|
||||
function rHost(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
|
||||
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
|
||||
const flags=["--pm","--no-launch"];
|
||||
if(a.join(' ')==='bus status --json'){
|
||||
if(fault(`status-${id}`))process.exit(1);
|
||||
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
|
||||
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
|
||||
console.log(JSON.stringify(s));process.exit(0);
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
|
||||
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
|
||||
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
|
||||
// host-touches lists files a careless host would write: the live config, business file or backup.
|
||||
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
|
||||
if(!off||fault('host-launches')){
|
||||
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
|
||||
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
|
||||
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
|
||||
}
|
||||
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
|
||||
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
|
||||
setInterval(()=>{},1<<30);
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
|
||||
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
|
||||
function rMigrate(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const home=process.env.HOME,data=process.argv[2];
|
||||
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
|
||||
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
|
||||
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
|
||||
console.log(`bus schema ${v} ready`);
|
||||
}
|
||||
|
||||
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
|
||||
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
|
||||
import {join} from 'node:path';
|
||||
export class Store {
|
||||
constructor(root) {
|
||||
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
|
||||
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
|
||||
}
|
||||
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
||||
close() { this.db.close(); }
|
||||
}
|
||||
`;
|
||||
|
||||
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
|
||||
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
|
||||
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
||||
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
||||
const d=join(RCOPIES,id);
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
||||
db.close();
|
||||
mkdirSync(e.state,{recursive:true});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
|
||||
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
|
||||
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
|
||||
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
|
||||
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
|
||||
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
|
||||
e.biz=join(dirname(e.cfg),'businesses','acme.json');
|
||||
return e;
|
||||
}
|
||||
|
||||
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
|
||||
// the backup, by sha256 of every file.
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
const [code]=await once(ch,'close');clearTimeout(timer);
|
||||
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
|
||||
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
|
||||
}
|
||||
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
|
||||
// function so a shell "$$" in `to` stays as written.
|
||||
function ownScript(e,from,to){
|
||||
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
||||
e.sr=join(own,'scripts','stack-release.sh');
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(r.err,'');
|
||||
assert.equal(untouched(e),before);
|
||||
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
|
||||
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
|
||||
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
|
||||
// The host wrote the scratch store, a copy: the backup has no such row.
|
||||
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
|
||||
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
|
||||
// The scratch config differs from the live one only in dataRoot.
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
|
||||
// The notifier config: binding null at 0600.
|
||||
const nf=join(R,'data','notify','acme','notify.json');
|
||||
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
|
||||
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
|
||||
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
|
||||
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
|
||||
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
|
||||
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
|
||||
assert.ok(refs.length>=2);
|
||||
for(const [name,ref,old] of refs){
|
||||
const f=join(R,'placeholders',name);
|
||||
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
|
||||
}
|
||||
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
|
||||
// Everything else in the business is as it was.
|
||||
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
|
||||
assert.deepEqual(strip(sb),strip(live));
|
||||
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
|
||||
// The host ran with launches disabled and started no PM; the Console got no --send.
|
||||
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(rh(e,'starts.log')),false);
|
||||
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
|
||||
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
||||
});
|
||||
|
||||
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
|
||||
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
|
||||
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
|
||||
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
|
||||
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
|
||||
});
|
||||
|
||||
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
|
||||
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
|
||||
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
|
||||
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
|
||||
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
});
|
||||
|
||||
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,'host-ignores-term');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
|
||||
});
|
||||
|
||||
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
|
||||
});
|
||||
|
||||
test('R7 a host on another copy fails copy-match',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
|
||||
});
|
||||
|
||||
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);
|
||||
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
|
||||
const p=String(held.address().port);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
|
||||
for(const q of ['7332','7333',e.port,TESTER]){
|
||||
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
|
||||
}
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
// The live pm gitea reference names a file that is there, as a live token would be.
|
||||
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
const before=untouched(e);
|
||||
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
// An env reference names no file at all; the guard says which variable it is.
|
||||
const f=await rmk(t);
|
||||
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
|
||||
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
|
||||
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
|
||||
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
|
||||
// the guard catches a path that reaches the live data root through a link.
|
||||
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
|
||||
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
// A root that is neither live nor this rehearsal's, and one that is not there.
|
||||
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
|
||||
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
|
||||
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
|
||||
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(q.code,1,dir+q.out+q.err);
|
||||
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
|
||||
assert.equal(existsSync(rh(g,'host-args.log')),false);
|
||||
}
|
||||
const f=await rmk(t);
|
||||
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
|
||||
// The file is left as it was: the rehearsal never deletes it and retries.
|
||||
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
|
||||
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
|
||||
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
|
||||
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
|
||||
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
|
||||
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
|
||||
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
|
||||
const g=await rmk(t);rmSync(g.pointer);
|
||||
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
|
||||
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
|
||||
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
|
||||
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
|
||||
}
|
||||
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
|
||||
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
|
||||
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
|
||||
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
|
||||
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
|
||||
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,sig+r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
|
||||
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
|
||||
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
||||
}
|
||||
});
|
||||
@@ -186,17 +186,22 @@ export function endReason(code, signal) {
|
||||
|
||||
// The host's view of its running sessions, kept in memory and mirrored to
|
||||
// sessions.json for `mosaic stop` and `mosaic launches list`.
|
||||
// readOnly (row 163, a --no-launch host): sessions.json stays as an earlier host left it, so a
|
||||
// later host still finds and ends those runs. add() refuses, and nothing writes the file.
|
||||
export class Registry {
|
||||
#dataRoot;
|
||||
#sessions = [];
|
||||
constructor(dataRoot) {
|
||||
#readOnly;
|
||||
constructor(dataRoot, { readOnly = false } = {}) {
|
||||
this.#dataRoot = dataRoot;
|
||||
this.#readOnly = readOnly;
|
||||
this.#write();
|
||||
}
|
||||
get sessions() {
|
||||
return this.#sessions.map((s) => ({ ...s }));
|
||||
}
|
||||
add(entry) {
|
||||
if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);
|
||||
this.#sessions.push({ ...entry });
|
||||
this.#write();
|
||||
}
|
||||
@@ -213,6 +218,7 @@ export class Registry {
|
||||
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
|
||||
}
|
||||
#write() {
|
||||
if (this.#readOnly) return;
|
||||
const file = sessionsFile(this.#dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
|
||||
@@ -133,6 +133,23 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
|
||||
});
|
||||
|
||||
// Row 163: a --no-launch host's registry leaves an earlier host's sessions.json as it was.
|
||||
test("a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove()", (t) => {
|
||||
const root = scratch(t);
|
||||
new Registry(root).add({ business: "acme", instance: "chief", run: "r1", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
|
||||
const before = readFileSync(sessionsFile(root), "utf8");
|
||||
const r = new Registry(root, { readOnly: true });
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
assert.deepEqual(r.sessions, []);
|
||||
assert.throws(() => r.add({ business: "acme", instance: "coder", run: "r2" }), (e) => e instanceof SeatError && /keeps no sessions/.test(e.message));
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
r.remove("acme", "r1");
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
const empty = scratch(t);
|
||||
new Registry(empty, { readOnly: true });
|
||||
assert.equal(existsSync(sessionsFile(empty)), false);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread instance runs once per thread, so the registry
|
||||
// keys a thread run by its thread too.
|
||||
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
|
||||
|
||||
+304
-2
@@ -15,6 +15,8 @@
|
||||
# 4. this script never prunes backups, rollback sets, post-attempt directories or copies.
|
||||
# Decisions 94 and 102 still decide who starts the Console; webui-service.sh keeps having no start
|
||||
# or stop verb. status and --plan only read, and every seat may run them.
|
||||
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
|
||||
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
|
||||
#
|
||||
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
|
||||
# every step that can fail carries its own `|| return 1`.
|
||||
@@ -28,7 +30,7 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
CONSOLE_UNIT=mosaic-webui.service
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
@@ -38,6 +40,7 @@ usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
common options: --copies DIR, --bin DIR
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
@@ -84,11 +87,14 @@ parse_opts() {
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
|
||||
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
|
||||
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
|
||||
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
|
||||
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
|
||||
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
|
||||
@@ -1155,13 +1161,307 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- The rehearsal (D2, row 163) ---
|
||||
|
||||
# rehearse runs the new copy's bus host (--no-launch) and Console on a scratch root built from a
|
||||
# backup, under <dataRoot>/rehearsals/<stamp>/, and gates them as activate does. It never writes the
|
||||
# live store, the live config files, the backup, the pointer, the log or a unit. Nothing prunes
|
||||
# the rehearsal directory. Design, rulings (mos-0961, mos-0962, mos-0963) and the deviations from
|
||||
# the brief: agents/sage/work/queue-163/BUILD.md.
|
||||
|
||||
# One PASS or FAIL line per check, on stdout and in steps.log. Lines from before the directory
|
||||
# exists wait in R_EARLY and go in first.
|
||||
r_line() {
|
||||
echo "$*"
|
||||
if [ -z "$STEPS" ]; then R_EARLY+=("$(now) $*"); else echo "$(now) $*" >> "$STEPS" || true; fi
|
||||
}
|
||||
r_pass() { r_line "PASS $1${2:+: $2}"; }
|
||||
r_fail() { r_line "FAIL $1${2:+: $2}"; R_RC=1; }
|
||||
r_abort() { r_fail "$@"; r_end; }
|
||||
|
||||
r_alive() { local s; s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1; s="${s##*) }"; [ "${s:0:1}" != Z ]; }
|
||||
r_gone() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
else kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped" "pid $pid was still running ${TIMEOUT}s after SIGTERM; sent SIGKILL"
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
[ -n "$R_STARTED" ] || return 0
|
||||
R_STARTED=""
|
||||
if [ -e "$R/data/bus/writer.lock" ]; then r_fail host-released-store "$R/data/bus/writer.lock is still there"; else r_pass host-released-store; fi
|
||||
}
|
||||
|
||||
r_tree_sums() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum); }
|
||||
|
||||
# The last checks, then the last line. The backup and the live config files are compared with the
|
||||
# sums taken before the rehearsal directory was made.
|
||||
r_end() {
|
||||
trap - INT TERM HUP
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
exit "$R_RC"
|
||||
}
|
||||
|
||||
r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
# own 0600 placeholder file. The service and its rotateBy or expires date stay. The tracker key
|
||||
# stays because the loader requires it. Mode `normalize` prints the live file as this writes it,
|
||||
# for the diff.
|
||||
R_BUSINESS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [mode,live,out,ph]=process.argv.slice(1);
|
||||
const b=JSON.parse(fs.readFileSync(live,"utf8"));
|
||||
if(mode==="normalize"){process.stdout.write(JSON.stringify(b,null,2)+"\n");process.exit(0)}
|
||||
const DATE={gitea:"rotateBy",vikunja:"expires"};
|
||||
const strip=(vars)=>{if(vars&&typeof vars==="object")for(const k of Object.keys(vars))if(k.startsWith("tracker."))delete vars[k]};
|
||||
const swap=(name,refs)=>{
|
||||
if(!refs||typeof refs!=="object")return;
|
||||
for(const s of Object.keys(refs)){
|
||||
const file=path.join(ph,`${name}-${s}`),old=refs[s],ref={file};
|
||||
fs.writeFileSync(file,"rehearsal-placeholder-token",{mode:0o600,flag:"wx"});
|
||||
if(old&&typeof old==="object"&&Object.hasOwn(DATE,s)&&old[DATE[s]]!==undefined)ref[DATE[s]]=old[DATE[s]];
|
||||
refs[s]=ref;
|
||||
}
|
||||
};
|
||||
strip(b.vars);
|
||||
for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}
|
||||
swap("@sync",b.tracker?.sync?.credentials);
|
||||
fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
|
||||
# The guard, before the host starts. It reads the scratch config through the new copy's own
|
||||
# loadSystem and bootConfig, as the host will, and prints one line per check:
|
||||
# data-root-live the data root is not the live one (mos-0961)
|
||||
# sessions-live no sessions.json there names a running process (mos-0961)
|
||||
# data-root-scratch the data root is the scratch one
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
(async()=>{
|
||||
let c,system,config;
|
||||
try{c=await import(pathToFileURL(`${copy}/packages/cli/src/config.mjs`).href);system=c.loadSystem({env:process.env})}
|
||||
catch(e){fail("scratch-config",String(e?.message??e).split("\n")[0])}
|
||||
const root=real(system.dataRoot);
|
||||
if(root===null)fail("data-root-scratch",`${system.dataRoot} does not exist`);
|
||||
if(root===real(live))fail("data-root-live",`the scratch config names dataRoot ${system.dataRoot}, which resolves to the live data root ${live}`);
|
||||
pass("data-root-live",`${system.dataRoot} is not the live data root`);
|
||||
const sf=path.join(root,"bus-host","sessions.json");
|
||||
if(fs.existsSync(sf)){
|
||||
let doc;try{doc=JSON.parse(fs.readFileSync(sf,"utf8"))}catch{fail("sessions-live",`${sf} is unreadable`)}
|
||||
if(!Array.isArray(doc?.sessions))fail("sessions-live",`${sf} is malformed`);
|
||||
const running=doc.sessions.flatMap((s)=>[s?.pid,s?.runnerPid]).filter((p)=>Number.isSafeInteger(p)&&p>0&&alive(p));
|
||||
if(running.length)fail("sessions-live",`${sf} names running pid(s) ${running.join(", ")}`);
|
||||
}
|
||||
pass("sessions-live","no running session named in the data root");
|
||||
if(root!==real(scratch))fail("data-root-scratch",`the scratch config resolves to ${system.dataRoot}, not ${scratch}`);
|
||||
pass("data-root-scratch");
|
||||
const nf=path.join(root,"notify",id,"notify.json");
|
||||
let st,doc;try{st=fs.lstatSync(nf);doc=JSON.parse(fs.readFileSync(nf,"utf8"))}catch{fail("notify-off",`${nf} is missing or not JSON`)}
|
||||
if(!st.isFile()||(st.mode&0o777)!==0o600||JSON.stringify(doc)!==JSON.stringify({notifyVersion:1,binding:null}))fail("notify-off",`${nf} is not {"notifyVersion":1,"binding":null} at mode 0600`);
|
||||
pass("notify-off","binding null, so no Discord bridge");
|
||||
try{config=c.bootConfig({system,businessId:id,env:process.env})}catch(e){fail("boot-config",String(e?.message??e).split("\n")[0])}
|
||||
if(config.trackers!==undefined)fail("tracker-configured",`bootConfig sets trackers for ${Object.keys(config.trackers).join(", ")}`);
|
||||
pass("tracker-configured","bootConfig sets no trackers");
|
||||
const b=config.businesses?.[id],phr=real(ph),refs=[];
|
||||
for(const [role,r] of Object.entries(b?.roles??{}))for(const [s,ref] of Object.entries(r?.credentials??{}))refs.push([`${role} ${s}`,ref]);
|
||||
for(const [s,ref] of Object.entries(b?.tracker?.sync?.credentials??{}))refs.push([`@sync ${s}`,ref]);
|
||||
for(const [what,ref] of refs){
|
||||
if(ref?.env!==undefined)fail("credential-ref-live",`${what} is env ${ref.env}`);
|
||||
const f=typeof ref?.file==="string"?real(ref.file):null;
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
# (de352dfaf's verify-open.mjs). Prints OPEN_OK <version> or REFUSED <message>.
|
||||
R_OPEN_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,root]=process.argv.slice(1);
|
||||
(async()=>{
|
||||
const {Store}=await import(pathToFileURL(`${copy}/packages/bus/src/store.mjs`).href);
|
||||
let s;
|
||||
try{s=new Store(root);process.stdout.write(`OPEN_OK ${s.get("SELECT value FROM meta WHERE key=?","schema_version").value}\n`)}
|
||||
catch(e){process.stdout.write(`REFUSED ${e.message}\n`)}
|
||||
finally{s?.close()}
|
||||
})();'
|
||||
r_open() { node -e "$R_OPEN_JS" -- "$1" "$2" 2>&1 || true; }
|
||||
|
||||
# Every process this rehearsal starts gets only HOME, PATH and the scratch MOSAIC_CONFIG.
|
||||
r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$@"; }
|
||||
|
||||
# The de352dfaf checks, when the backup's schema is not the new copy's.
|
||||
r_migration() {
|
||||
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
|
||||
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
|
||||
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
|
||||
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
|
||||
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
|
||||
out="$(r_open "$copy" "$R/refuse")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort new-refuses-old-store "$TO opened the $v store: $out"
|
||||
r_pass new-refuses-old-store "$TO refuses the $v store at a normal open"
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate "$v to $T_SCHEMA"
|
||||
out="$(r_open "$copy" "$R/data")"
|
||||
[ "$out" = "OPEN_OK $T_SCHEMA" ] || r_abort new-opens-migrated "$out"
|
||||
r_pass new-opens-migrated
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate-again "second migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate-again "a second run is a no-op"
|
||||
out="$(r_open "$old" "$R/data")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort old-refuses-migrated "the pointer's copy $P_COPY opened the $T_SCHEMA store: $out"
|
||||
r_pass old-refuses-migrated "$P_COPY refuses the $T_SCHEMA store"
|
||||
}
|
||||
|
||||
# One pass of D1's gate checks but the PM's, against the processes this run started. Sets R_GATE
|
||||
# to the first failing check and R_WHY to what it saw.
|
||||
r_gate_once() {
|
||||
local copy="$COPIES/$TO" out
|
||||
R_GATE=host-up R_WHY=""
|
||||
r_alive "$R_HOST_PID" || { R_WHY="the host (pid $R_HOST_PID) exited; see $R/host.log"; return 1; }
|
||||
grep -q "^bus host up: business $BUSINESS, .*, launches disabled\$" "$R/host.log" || { R_WHY="no \"bus host up ... launches disabled\" line in $R/host.log"; return 1; }
|
||||
R_GATE=bus-status
|
||||
out="$(r_env "$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { R_WHY="bus status failed"; return 1; }
|
||||
R_WHY="$(node -e '
|
||||
const [b,main,since]=process.argv.slice(2),s=JSON.parse(process.argv[1]);
|
||||
const why=!s.host?"no host":s.host.business!==b?`host for ${s.host.business}`:s.host.live!==true?"host not live":String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run started`:!s.boot||s.boot.ok!==true?"boot status not ok":s.boot.business!==b?`boot status for ${s.boot.business}`:!(Date.parse(s.boot.at)>=Number(since))?"boot status is from before this start":"";
|
||||
process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1)" || { R_WHY="bus status unreadable"; return 1; }
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
R_GATE=console-up
|
||||
r_alive "$R_CONSOLE_PID" || { R_WHY="the Console (pid $R_CONSOLE_PID) exited; see $R/console.log"; return 1; }
|
||||
grep -qxF "Mosaic Console: http://127.0.0.1:$R_PORT/" "$R/console.log" || { R_WHY="no \"Mosaic Console: http://127.0.0.1:$R_PORT/\" line in $R/console.log"; return 1; }
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
R_GATE=""
|
||||
}
|
||||
|
||||
r_gate() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
until r_gate_once; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
r_no_session() {
|
||||
local found=""
|
||||
[ ! -e "$R/data/bus-host/sessions.json" ] || found+=" bus-host/sessions.json"
|
||||
[ ! -e "$R/data/launches" ] || found+=" launches"
|
||||
if [ -z "$found" ]; then r_pass no-session "no sessions.json and no launches under $R/data"; else r_fail no-session "found$found under $R/data"; fi
|
||||
}
|
||||
|
||||
do_rehearse() {
|
||||
TO="$ID"
|
||||
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
|
||||
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
|
||||
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
|
||||
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
# mos-0961: an older copy's host has no --no-launch and would start the PM on its own.
|
||||
grep -qF -- '"--no-launch"' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null || r_abort copy-without-no-launch "$TO's bus host has no --no-launch flag"
|
||||
r_pass copy "$TO, schema $T_SCHEMA"
|
||||
local i
|
||||
local -a reserved=(7332 7333 "$(console_port)")
|
||||
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
|
||||
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
|
||||
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
|
||||
r_pass port "127.0.0.1:$R_PORT is free"
|
||||
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
|
||||
[ -f "$R_BK/bus/bus.sqlite" ] && [ -f "$R_BK/SHA256SUMS" ] || r_abort backup-missing "$R_BK has no bus/bus.sqlite and SHA256SUMS"
|
||||
(cd "$R_BK/bus" && sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 || r_abort backup-sums "$R_BK/bus does not match its SHA256SUMS"
|
||||
r_pass backup "$R_BK matches its SHA256SUMS"
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
r_migration
|
||||
|
||||
START_MS="$(date +%s%3N)"
|
||||
R_STARTED=1
|
||||
# env, not r_env: a function in the background is a subshell, and $! has to be the process
|
||||
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
|
||||
R_HOST_PID=$!
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
ID=""
|
||||
case "$OP" in
|
||||
activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) usage ;;
|
||||
@@ -1179,10 +1479,12 @@ BUS_UNIT="mosaic-bus@$BUSINESS.service"
|
||||
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
activate) do_activate ;;
|
||||
pm-on) do_pm_on ;;
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
rehearse) do_rehearse ;;
|
||||
esac
|
||||
Reference in new issue
Block a user