feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Slice 1 S5: Gate E script (row 40, #1522; row 5, #1507)
|
||||
|
||||
Written 2026-10-09T23:28Z by Dewey. This replaces the draft in
|
||||
`SLICE1-VIEWS.md` section 4, now that S3 and S4 have landed. Gate E is
|
||||
Jason's: the demonstration with all seats, then his workday ruling (round 3,
|
||||
7B). Live cutover needs its own approval. Model calls during the
|
||||
demonstration are Jason's to start; no seat runs them for discovery.
|
||||
|
||||
## What is ready, and what waits
|
||||
|
||||
| Step | Needs | State on 2026-10-09 |
|
||||
|---|---|---|
|
||||
| 1 Console, board and Agents | S4 human CLI, a bus host | ready |
|
||||
| 2 A task appears in Tasks | S3 poller and Vikunja (tasks.mosaicstack.dev, project 32) | ready; the PM request itself waits on S6 |
|
||||
| 3 A decision through the inbox | S4 `mosaic decide` | ready |
|
||||
| 4 Trail to review and close | S2 trail, S3 events | ready |
|
||||
| 5 A conversation through CHAT-03 | the I3 seal and environment (this row); the controller entry point for the live PM session (S6) | the library is ready; a live session waits on S6 |
|
||||
| 6 Workday ruling | all of the above | Jason |
|
||||
|
||||
Nothing in `packages/` constructs a `Controller` outside the tests yet.
|
||||
S6 is where the stack launches the PM session; this row makes the controller
|
||||
safe to point at real Pi (the seal covers the command, the environment is
|
||||
an explicit list).
|
||||
|
||||
## Script
|
||||
|
||||
1. Start the bus host, the board and the Console, one terminal each:
|
||||
|
||||
```sh
|
||||
scripts/mosaic bus start mosaic-stack
|
||||
node packages/control-board/src/cli.mjs serve
|
||||
node packages/webui/src/cli.mjs serve
|
||||
```
|
||||
|
||||
The Console prints `Bus: the running bus host's business`. Open
|
||||
http://127.0.0.1:7330/. The board shows the seats. Agents shows each held
|
||||
role with its holder and harness; the gap labels say what the Q1 module
|
||||
doesn't return yet.
|
||||
2. Send the PM a one-sentence request (with S6: `mosaic talk`). Watch the
|
||||
task appear in Tasks with its requirement id within one poll. Open it:
|
||||
the snapshots say whether the stack wrote it or Vikunja was changed after.
|
||||
Edit the task in Vikunja and watch the badge change on the next poll.
|
||||
3. A gated decision reaches the inbox and the DM. Read it in Console, Copy
|
||||
the `mosaic decide <id> <key>` line and run it in a terminal (it asks for
|
||||
confirmation unless `--yes`). The inbox row closes on the next refresh,
|
||||
and the decision page shows the resolution.
|
||||
4. From the task page, follow "Trail with filters" through review and close.
|
||||
Each event names who did it; a decision link goes back to the inbox page.
|
||||
5. Drive a conversation through CHAT-03: connect the mediated terminal
|
||||
(`node packages/conversation/src/terminal.mjs --socket <path>`), watch as
|
||||
an observer, take control with Ctrl-T, send, then let another client take
|
||||
it back. The composer clears on each transfer.
|
||||
6. Use Console for the rest of the workday, then rule on it. Pass is Jason's
|
||||
say-so, not a test result.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Browser runs against an in-process broker with real broker rows:
|
||||
`packages/webui/tests/bus-browser.test.mjs`, screenshots under
|
||||
`WEBUI_EVIDENCE`.
|
||||
- The live run's screen captures, taken during the demonstration and kept
|
||||
under `agents/dewey/work/wui/evidence/`.
|
||||
- Anything that sent Jason to the board's own page, a terminal or Vikunja
|
||||
instead of Console goes in `docs/plans/DEFERRED.md`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Stop the WebUI. The board at 7331 and the CLI are unchanged. Revert the S5
|
||||
commit to remove the views; there is no data to migrate. Decisions resolved
|
||||
with `mosaic decide` belong to the bus and are not rolled back.
|
||||
@@ -213,6 +213,9 @@ whatever S6 records, through `packages/conversation`.
|
||||
|
||||
## 4. Gate E
|
||||
|
||||
The script for the demonstration is now `S5-GATE-E.md` (2026-10-09).
|
||||
This section stays as the design draft it replaced.
|
||||
|
||||
Round 3, 7B: Gate E is shown during this step, not separately. The brief:
|
||||
an interactive demonstration with all seats, then Jason's workday ruling.
|
||||
Live cutover still needs its own approval.
|
||||
|
||||
@@ -56,4 +56,4 @@ Brief: `docs/plans/2026-10-04_slice-1.md`, S5. S5 follows S4. Until then only de
|
||||
| --- | --- | --- | --- | --- |
|
||||
| S5-D1 | Design note: routes, the four views, data sources, Gate E draft, open questions. Evidence: SLICE1-VIEWS.md. | Schema v3 (7ed83178) | done | Revision 2 records lead decision 56 (Q1 to Q5) and the stale-read finding (section 8). Revision 3 records v3b (lead decision 60), the Q1 reshape and the gap list (section 9). |
|
||||
| S5-D2 | Console mockup of inbox, tasks, agents and trail from fixture rows in the Q1 module's shapes; checked at the usual widths, palettes and modes. | S5-D1 | in review | `mockups/slice1/`; `checks/slice1-verify.mjs`: 15 checks, 0 failed, 2 not verified (forced colors, screen reader). Fixtures are raw schema v3b (179ffe35) rows; check 15 agrees with `task_current` and `tasks_open` on every task (lead decision 60). `q1.js` stands in for the four reader verbs, and check 16 matches it against the reader extracted from the S2c commit d27042fa (203 rows). S2b refused an agent message citing its decision; S2c (row 44, #1526, lead decision 65) restores it, and the probe confirms it on d27042fa (SLICE1-VIEWS.md section 10). The fixture carries the `message.send` `action.allowed` event for messages 101 and 102. Gaps for Rocko: SLICE1-VIEWS.md section 9. Next: switch to Rocko's pinned fixture when it arrives. Gaps went to S4, S6 and S3 (lead decision 63). |
|
||||
| S5-B1 | Build in `packages/webui`, `packages/conversation`, `packages/control-board`; I3 follow-ups; Gate E. | S4 done, S6 for the PM session | blocked | Wait for S4. |
|
||||
| S5-B1 | Build in `packages/webui`, `packages/conversation`, `packages/control-board`; I3 follow-ups; Gate E. | S4 done, S6 for the PM session | in progress | Row 40 in progress since 2026-10-09 (Sage). Views: `/api/bus/*` reads through the human transport, read-only, Copy for `mosaic decide`. CHAT-03: the seal covers the engine command, the engine environment is an explicit list, `escalating` clears after a throw, input after Ctrl-T or Ctrl-O waits for it (lead decision 56 Q5). Gate E script: `S5-GATE-E.md`; steps 2 and 5 wait on S6 for the live PM session. |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Slice 1 S5 mutation check (row 40, #1522)
|
||||
|
||||
Run 2026-10-09 by Dewey, finished 2026-10-10T00:01Z. Each mutant ran on a
|
||||
fresh scratch copy (`~/dewey-scratch/s5/mut`) of `packages/conversation`,
|
||||
`control-board`, `discord`, `seat`, `package.json`, `package-lock.json` and
|
||||
`docs/plans/chat-01`, with `node_modules` symlinked. Every mutant got the
|
||||
full conversation suite. The working tree was never mutated. The tools are
|
||||
`~/dewey-scratch/s5/mut-tools/` (`mutants.py`, `run.sh`).
|
||||
|
||||
| Mutant | Change | Result | Killed by |
|
||||
|---|---|---|---|
|
||||
| base | none | 157/157 | (baseline) |
|
||||
| esc | the force stop no longer clears `escalating` when the fence throws | killed | races: a force stop whose fence throws leaves no escalation flag behind |
|
||||
| seal | the seal check at bind always passes | killed | N24b |
|
||||
| keys | the envKeys check never finds a bad name | killed | N24b, N24 |
|
||||
| envall | the engine environment copies every name in the source | killed | N24b |
|
||||
| credname | envKeys skips the credential-name test | killed | N24b |
|
||||
| restart | `start()` skips the seal check | killed | N24b |
|
||||
| hold | no action holds input | killed | terminal: Ctrl-T then Enter in one chunk |
|
||||
| holdreload | only Ctrl-T holds input | killed | the same test's Ctrl-O case |
|
||||
| throwdrain | an action that throws drops the input held behind it | killed | terminal: an action that throws still releases the input held behind it |
|
||||
| manager | ScopeLauncher passes the engine environment without `MANAGER_ENV` | killed | K19 |
|
||||
|
||||
Two fixes came out of the runs:
|
||||
|
||||
- `manager` survived the first run, since no test launched a scope with only
|
||||
the engine environment (the cohort fixtures pass the whole test
|
||||
environment). K19 now launches `/bin/sleep` through ScopeLauncher with
|
||||
`engineEnv([])` and checks that the engine's environment names come only
|
||||
from ENGINE_ENV, MANAGER_ENV and the names systemd and the shim's shell
|
||||
set (`INVOCATION_ID`, `PWD`, `SHLVL`).
|
||||
- Under `seal` and `restart`, N24b failed but then held its controller's
|
||||
socket open, so `turns.test.mjs` ran until the 900 s timeout. The
|
||||
bind-time part of N24b now closes the controller in `finally`, and a
|
||||
regression there fails in seconds.
|
||||
|
||||
The first full run's baseline failed 2 tests because the copy lacked
|
||||
`docs/plans/chat-01/contracts.schema.json`. The runner now copies it.
|
||||
Results from that run were discarded.
|
||||
|
||||
## Round 2 (Filbert's comment 26993)
|
||||
|
||||
The same runner, the round 2 candidate, and every mutant from round 1, plus
|
||||
three for the round 2 changes. Each mutant ran the full conversation suite
|
||||
on a fresh scratch copy.
|
||||
|
||||
| Mutant | Change | Result | Killed by |
|
||||
|---|---|---|---|
|
||||
| base | none | 159/159 | (baseline) |
|
||||
| noapprove | `--no-approve` removed from SEAL_FLAGS | killed | smoke: sealed, pinned Pi ignores a trusted project's .pi resources |
|
||||
| queuecatch | `key()` chains on the queue without catching the previous chunk's throw | killed | terminal: after an action throws, later input still runs |
|
||||
| inputcatch | `input()` rethrows instead of setting the status line | killed | the same test |
|
||||
| esc | as round 1 | killed | races: a force stop whose fence throws leaves no escalation flag behind |
|
||||
| seal | as round 1 | killed | N24b |
|
||||
| keys | as round 1 | killed | N24b, N24 |
|
||||
| envall | as round 1 | killed | N24b, K19 |
|
||||
| credname | as round 1 | killed | N24b |
|
||||
| restart | as round 1 | killed | N24b |
|
||||
| hold | as round 1 | killed | terminal: Ctrl-T then Enter in one chunk; after an action throws |
|
||||
| holdreload | as round 1 | killed | terminal: Ctrl-T then Enter in one chunk (Ctrl-O case) |
|
||||
| throwdrain | as round 1 | killed | terminal: an action that throws still releases the input held behind it; after an action throws |
|
||||
| manager | as round 1 | killed | K19 |
|
||||
|
||||
`noapprove` is checked against the real pinned Pi 0.85.1 with no model
|
||||
call. The test's control runs the same trusted project with `--approve`
|
||||
after the seal, and that offers `skill:probe`, so the sealed assertion
|
||||
can see a load when one happens.
|
||||
|
||||
## Round 3 (Darkwing's comment 27001, Filbert's comment 27005)
|
||||
|
||||
The same runner on the round 3 candidate. The terminal mutants were rewritten for the new `#run`/`#feed` code, and the rest are as in round 2. Each mutant ran the full conversation suite on a fresh scratch copy.
|
||||
|
||||
| Mutant | Change | Result | Killed by |
|
||||
|---|---|---|---|
|
||||
| base | none | 161/161 | (baseline) |
|
||||
| holdowner | every run drains `held`, as in round 2 (T1) | killed, 160/161 | terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending |
|
||||
| heldcatch | held input's `key()` returns the holder's promise (N4) | killed, 160/161 | terminal: input() reports an error when it happens … |
|
||||
| chunkstop | one try around the chunk's actions, so a throw skips the rest of the chunk | killed, 160/161 | the same test |
|
||||
| reportlate | `input()` catches at the end, as in round 2 (N4) | killed, 160/161 | the same test |
|
||||
| reportdrop | `input()`'s error is thrown and swallowed, never shown | killed, 159/161 | terminal: after an action throws, later input still runs; input() reports an error when it happens |
|
||||
| throwdrain | a throw leaves `#run` before the held input | killed, 158/161 | terminal: an action that throws still releases the input held behind it; after an action throws; input() reports an error when it happens |
|
||||
| queuecatch | as round 2 | killed, 160/161 | terminal: after an action throws, later input still runs |
|
||||
| noapprove | as round 2 | killed, 160/161 | smoke: sealed, pinned Pi ignores a trusted project's .pi resources |
|
||||
| esc | as round 1 | killed, 160/161 | races: a force stop whose fence throws leaves no escalation flag behind |
|
||||
| seal | as round 1 | killed, 160/161 | N24b |
|
||||
| keys | as round 1 | killed, 159/161 | N24b, N24 |
|
||||
| envall | as round 1 | killed, 159/161 | N24b, K19 |
|
||||
| credname | as round 1 | killed, 160/161 | N24b |
|
||||
| restart | as round 1 | killed, 160/161 | N24b |
|
||||
| hold | as round 1 | killed, 157/161 | terminal: Ctrl-T then Enter in one chunk; input held behind Ctrl-T waits for that takeover while an earlier action is still pending; after an action throws; input() reports an error when it happens |
|
||||
| holdreload | as round 1 | killed, 160/161 | terminal: Ctrl-T then Enter in one chunk (Ctrl-O case) |
|
||||
| manager | as round 1 | killed, 160/161 | K19 |
|
||||
|
||||
Round 2's `inputcatch` (`input()` rethrows) has no counterpart here: `input()` no longer catches a rejection, and with a reporter `#run` never throws. `reportdrop` and `reportlate` cover its two halves.
|
||||
|
||||
WebUI (Darkwing's note 1). `run.sh` copies only the conversation packages, so these ran `packages/webui/tests/bus.test.mjs` on a scratch copy of the packages it imports:
|
||||
|
||||
| Mutant | Change | Result | Killed by |
|
||||
|---|---|---|---|
|
||||
| base | none | 7/7 | (baseline) |
|
||||
| Mm | `humanCall`'s timeout rejects without `child.kill('SIGKILL')` | killed, 6/7 | humanCall kills a transport that runs past its timeout |
|
||||
| Mp | `serve --business` takes any value | killed, 6/7 | serve refuses a --business value that is not a business id |
|
||||
|
||||
Correction: the first Mm run hung, because the surviving child held the test process open. I stopped the child by PID. The test now kills a surviving child on cleanup, and the rerun failed the assertion in 1.5 s. Under Mp, `serve` started a real server on port 0 until spawnSync's 10 s timeout ended it.
|
||||
+29
-12
@@ -187,24 +187,21 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
(2026-10-04)
|
||||
- **CHAT-03 I1 round 2 follow-ups, for the I3 brief.** Neither reviewer
|
||||
held any of these as blocking (Darkwing comment 26690, Filbert comment
|
||||
26694 on #1507).
|
||||
- The engine command and `preArgs` sit outside the seal. They're
|
||||
documented as a test hook, but `preArgs` with the default command
|
||||
reaches real Pi unsealed. The I3 entry point must not take them from
|
||||
config. Both reviewers found this.
|
||||
- `engine.env` defaults to `process.env`. I3 builds the engine's
|
||||
environment from an explicit list. Darkwing F3.
|
||||
- `escalating` is set in `controller.mjs` before `after()` runs. If
|
||||
admission or poison throws, the flag stays set, and later force stops
|
||||
are refused as `fenced` until restart. Darkwing F2.
|
||||
26694 on #1507). Four of the five are closed by slice 1 S5 (see Done);
|
||||
this one stays open.
|
||||
- No test proves that the processes are listed only after the cgroup
|
||||
is frozen. The code waits for `frozen 1` in `cgroup.events`, and
|
||||
both Filbert and Dewey confirmed it by reading it. Mutants r2-B5b and
|
||||
C4 survive. Proposed: a test-only hold after `members`, or a FUSE or
|
||||
privileged fixture. Filbert F1.
|
||||
- A takeover followed by Enter in the same chunk leaves the text
|
||||
unsent. Filbert F2.
|
||||
(2026-10-04, #1507)
|
||||
- **CHAT-03 `engine.envKeys` accepts founder credential names.**
|
||||
`CREDENTIAL_NAME` in `packages/conversation/src/pi-pin.mjs` matches any
|
||||
`*_API_KEY` or `*_TOKEN`, including `GITEA_TOKEN` and `GH_TOKEN`, which
|
||||
the S6 runner refuses as founder credentials (REQ-CRED-2). Nothing
|
||||
builds a Controller outside the tests yet. Whoever wires the entry point
|
||||
narrows the pattern to provider names or checks the founder list.
|
||||
Filbert N2 on #1522. (2026-10-10)
|
||||
- **`test-task.sh` makes a live Pi call without the Docker guard.** The
|
||||
"user recall" block near line 478 runs `run-task.sh` with no
|
||||
availability check, so a run with Docker unavailable fails "user recall
|
||||
@@ -264,3 +261,23 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
shared clones and 10 exports, one after another. All 20 passed
|
||||
148/148 (2960 of 2960). The 300 ms deadline test is unchanged. Counts:
|
||||
`agents/filbert/work/queue-33/cold-runs.txt`.
|
||||
- CHAT-03 I1 round 2 follow-ups (2026-10-04, #1507): four of five closed
|
||||
by slice 1 S5 (row 40, #1522; Dewey authored, Darkwing and Filbert
|
||||
reviewing). The commit is named in the row's review record when it
|
||||
lands.
|
||||
- Engine command and `preArgs`: `engine` takes only `extraArgs`, `cwd`
|
||||
and `envKeys`. The controller always launches the pinned Pi and
|
||||
checks that at construction and at bind. The fake engine comes in
|
||||
through a symbol key that JSON config can't carry (N24b).
|
||||
- Engine environment: an explicit list (`ENGINE_ENV` in `pi-pin.mjs`)
|
||||
plus `*_API_KEY` and `*_TOKEN` names from `engine.envKeys`. Nothing
|
||||
else is inherited. `HOME` passes, so Pi reads `~/.pi/agent` unless
|
||||
`PI_CODING_AGENT_DIR` is set (Darkwing F3).
|
||||
- `escalating` is cleared when the force-stop fence throws (Darkwing F2,
|
||||
races.test.mjs).
|
||||
- Takeover then Enter in one chunk: input after Ctrl-T or Ctrl-O waits
|
||||
for that action, in the same chunk or a later one, and also while an
|
||||
earlier action is still pending, so it is judged as if typed one key
|
||||
at a time (Filbert F2; Darkwing and Filbert T1 on #1522;
|
||||
flows.test.mjs).
|
||||
The frozen-cgroup test (Filbert F1) stays open above.
|
||||
|
||||
@@ -267,23 +267,62 @@ Each is a reading of the brief or a lead decision, recorded so a reviewer
|
||||
can disagree with it.
|
||||
|
||||
- **Seal.** The argv is `--mode rpc --no-extensions --no-prompt-templates
|
||||
--no-themes --session <absolute file>`, then optional `engine.extraArgs`.
|
||||
--no-themes --no-approve --session <absolute file>`, then optional
|
||||
`engine.extraArgs`. `--no-approve` keeps a project's `.pi/settings.json`,
|
||||
`SYSTEM.md`, `APPEND_SYSTEM.md` and skills out even when the operator's
|
||||
`~/.pi/agent/trust.json` trusts the project, as it trusts this checkout
|
||||
on the build host. Without it a project `SYSTEM.md` would replace the
|
||||
system prompt and a project `settings.json` could choose the binary the
|
||||
bash tool runs (`shellPath`) (Filbert F2 on #1522; smoke.test.mjs runs
|
||||
the real Pi with a trusted project, sealed and with `--approve`).
|
||||
The seal doesn't pass `--no-context-files`, so Pi still loads `AGENTS.md`
|
||||
or `CLAUDE.md` from `~/.pi/agent`, the engine's working directory and
|
||||
its parents into the system prompt (Pi's usage.md, "Context Files").
|
||||
That is instruction text, not settings or code; whoever sets
|
||||
`engine.cwd` chooses it (Darkwing's note on #1522).
|
||||
The seal is an allow-list: extraArgs may carry only `--model`,
|
||||
`--provider` and `--thinking`, each at most once with one plain value
|
||||
(not starting with `-` or `@`). Anything else refuses `unsealed-engine`
|
||||
at construction and again at bind, before spawn: an `-e`/`--extension`
|
||||
argument, a missing `--no-*` flag, a second `--mode` or `--session` (Pi
|
||||
keeps the last of each), a session or output flag (`--no-session`,
|
||||
`--fork`, `--export`, `--print`, `--continue`, ...) or a bare word, which
|
||||
Pi reads as a prompt (lead decision 31; N24, including the missing flag
|
||||
argument, a missing `--no-*` flag, `--approve`, a second `--mode` or
|
||||
`--session` (Pi keeps the last of each), a session or output flag
|
||||
(`--no-session`, `--fork`, `--export`, `--print`, `--continue`, ...) or a
|
||||
bare word, which Pi reads as a prompt (lead decision 31; N24, including the missing flag
|
||||
through `checkSeal`).
|
||||
- **Engine command.** `engine.command` and `engine.preArgs` default to
|
||||
`node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`.
|
||||
A non-default value is a test hook for the fake engine. The pin check
|
||||
reads only the lock files under `pinRoot` and the seal checks only Pi's
|
||||
arguments, so neither says what runs under an overridden command. The
|
||||
binding's `argvDigest` records the full command line. `preArgs` carrying
|
||||
`--extension` still refuses.
|
||||
- **Engine command and environment (slice 1 S5, #1522).** The controller
|
||||
always launches `node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`
|
||||
and checks that at construction and again at bind. `engine` takes only
|
||||
`extraArgs`, `cwd` and `envKeys`; any other key (`command`, `preArgs`,
|
||||
`env`), or an `engine` that isn't an object, refuses `unsealed-engine`.
|
||||
The fake engine comes in through `opts[TEST_ENGINE]`, a symbol key that
|
||||
JSON config can't carry, so no config file reaches an unsealed command.
|
||||
`preArgs` carrying `--extension` still refuses there too. The binding's
|
||||
`argvDigest` records the full command line.
|
||||
- **Engine environment.** The engine gets `ENGINE_ENV` (`pi-pin.mjs`):
|
||||
`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_ALL`,
|
||||
`LC_CTYPE`, `TZ`, `TERM`, `TMPDIR`, `PI_CODING_AGENT_DIR`, `PI_OFFLINE`,
|
||||
`PI_SKIP_VERSION_CHECK` and `PI_TELEMETRY`, plus the names in
|
||||
`engine.envKeys`, which must look like a provider credential
|
||||
(`*_API_KEY` or `*_TOKEN`). The pattern also matches founder
|
||||
credentials such as `GITEA_TOKEN`, which the S6 runner refuses; whoever
|
||||
wires a launch must not name them (Filbert N2 on #1522, DEFERRED.md).
|
||||
A name that is unset is left out. Nothing
|
||||
else is inherited, so `NODE_OPTIONS`, `LD_PRELOAD` or `PI_PACKAGE_DIR`
|
||||
in the controller's environment never reach Pi (N24b). The tradeoff:
|
||||
`HOME` passes, so Pi reads the operator's `~/.pi/agent` unless
|
||||
`PI_CODING_AGENT_DIR` is set. That is where its credentials and model
|
||||
settings live, and dropping `HOME` would break them. `ScopeLauncher`
|
||||
adds `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` so `systemd-run
|
||||
--user` reaches the user manager; the engine inherits both, and neither
|
||||
names code to load. The user bus does let the engine ask the user manager
|
||||
to run a command outside its scope; that is the same-UID limit the
|
||||
project already accepts (Filbert N3 on #1522). The engine also sees `INVOCATION_ID` from systemd,
|
||||
and `PWD` (plus `SHLVL` under bash) from the shim's `/bin/sh`; none of
|
||||
them comes from the controller's environment (K19).
|
||||
- **A force stop that throws.** If admission or the poison throws after a
|
||||
force stop sets `escalating`, the flag is cleared before the error
|
||||
propagates, so the next force stop runs instead of refusing `fenced`
|
||||
(Darkwing F2 on #1507; races.test.mjs).
|
||||
- **Session key.** The claim's session key is the Pi header ID (D1), read
|
||||
at construction. A hard link or a copy of a session under another seat
|
||||
has a different conversation ID but the same header ID, so its
|
||||
@@ -402,6 +441,17 @@ A thin view over the client library; it renders the same `Transcript` (E7).
|
||||
`not admitted: controller` and sends nothing; the buffer is kept (S5).
|
||||
- Enter takes the composer at that key: text after it in the same input
|
||||
chunk starts the next message.
|
||||
- Input after Ctrl-T or Ctrl-O waits until that action finishes, whether it
|
||||
is in the same chunk or a later one, so it is judged as if typed one key
|
||||
at a time. Ctrl-T then `hi` and Enter in one chunk sends `hi` once the
|
||||
takeover lands; `hi`, Ctrl-T and Enter sends nothing, because the
|
||||
transfer clears the composer. The held input waits for its own Ctrl-T
|
||||
or Ctrl-O, not for an earlier action such as a slow Ctrl-G (Darkwing
|
||||
and Filbert T1 on #1522). If an action throws, the keys after it, the
|
||||
input held behind it and later input still run, in order (Filbert F2 on
|
||||
#1507, N1 on #1522). The terminal command shows the error in the status
|
||||
line (`failed: <reason>`) when it happens, instead of exiting, so a later
|
||||
status such as `prompt: admitted` is not overwritten (Filbert N4).
|
||||
- A bracketed paste is inserted literally, newlines included, and never
|
||||
submits by itself. A paste marker split across input chunks, even right
|
||||
after its ESC, is still a paste marker; a lone trailing ESC waits for the
|
||||
@@ -495,8 +545,8 @@ no prompt:
|
||||
|---|---|
|
||||
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
|
||||
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
|
||||
| `turns.test.mjs` | N1–N25: the turn tracker against the fake engine's Pi behaviors |
|
||||
| `cohort.test.mjs` | K1–K18: scopes, force stop, proofs, recovery, eligibility (needs a systemd user manager) |
|
||||
| `turns.test.mjs` | N1–N25, N24b: the turn tracker against the fake engine's Pi behaviors; the engine seal and environment |
|
||||
| `cohort.test.mjs` | K1–K19: scopes, force stop, proofs, recovery, eligibility, the scope's environment (needs a systemd user manager) |
|
||||
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
|
||||
| `smoke.test.mjs` | the pinned Pi binary, as above |
|
||||
|
||||
|
||||
@@ -95,9 +95,15 @@ export class ScopeLauncher {
|
||||
this.startTimeoutMs = startTimeoutMs;
|
||||
}
|
||||
|
||||
// systemd-run --user reaches the user manager through these two; a scope's
|
||||
// command inherits systemd-run's environment, so the engine sees them too.
|
||||
// Neither loads code.
|
||||
static MANAGER_ENV = Object.freeze(["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]);
|
||||
|
||||
async launch({ unitName, socketPath, command, args, cwd, env }) {
|
||||
const manager = Object.fromEntries(ScopeLauncher.MANAGER_ENV.filter((k) => typeof process.env[k] === "string").map((k) => [k, process.env[k]]));
|
||||
const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], {
|
||||
cwd, env, stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd, env: { ...manager, ...env }, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
|
||||
const end = Date.now() + this.startTimeoutMs;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
|
||||
import { LiveSessionGuard, realPath } from "./guard.mjs";
|
||||
import { ID, fragments, safeId } from "./parts.mjs";
|
||||
import { parseSnapshot } from "./pi.mjs";
|
||||
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal } from "./pi-pin.mjs";
|
||||
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal, engineEnv } from "./pi-pin.mjs";
|
||||
import { ACTOR, conversationId, createReader, rootsFromSpecs } from "./reader.mjs";
|
||||
import { clone, equal, hash, newId, receiptAllows, record, scopeMatch, sealProof, sha256, targetOf } from "./records.mjs";
|
||||
import { ControlRefusal, Refusal } from "./safe-fs.mjs";
|
||||
@@ -66,6 +66,12 @@ export const ALL_CAPABILITIES = Object.freeze(["observe", "send", "take-control"
|
||||
// CHAT-04 or I4 and refuse `unsupported-capability`.
|
||||
export const VERIFIED_OPERATIONS = Object.freeze(["observe", "prompt", "takeover", "acquire-recovery-control", "interrupt", "force-stop", "recover", "issue-confirmation", "answer-confirmation"]);
|
||||
|
||||
// The engine a test runs instead of Pi: `{ command, preArgs, env }`. A
|
||||
// symbol key, so no JSON configuration can carry it; the plain `engine`
|
||||
// option takes only extraArgs, cwd and envKeys (I3, both reviewers on #1507).
|
||||
export const TEST_ENGINE = Symbol("conversation.test-engine");
|
||||
const ENGINE_KEYS = new Set(["extraArgs", "cwd", "envKeys"]);
|
||||
|
||||
export const TIMEOUTS = Object.freeze({ ack: 5000, state: 5000, start: 5000, clear: 5000, abort: 10000, settle: 10000, grace: 1000, write: 5000, maxRounds: 3 });
|
||||
|
||||
const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]);
|
||||
@@ -160,17 +166,24 @@ export class Controller {
|
||||
this.project = project;
|
||||
this.workspace = workspace;
|
||||
this.conversation = conversationId(this.root, basename(this.paths.sessionFile));
|
||||
if (!engine || typeof engine !== "object" || Array.isArray(engine)) throw new ControlRefusal(UNSEALED_ENGINE, "engine is not an object");
|
||||
const extra = Object.keys(engine).find((k) => !ENGINE_KEYS.has(k));
|
||||
if (extra !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${extra.slice(0, 40)} can't be configured; the controller launches the pinned Pi with its own environment`);
|
||||
const test = opts[TEST_ENGINE] ?? null;
|
||||
this.engine = {
|
||||
command: engine.command ?? process.execPath,
|
||||
preArgs: engine.preArgs ?? [join(pinRoot, PI_BIN)],
|
||||
sealed: test === null,
|
||||
command: test ? test.command : process.execPath,
|
||||
preArgs: test ? test.preArgs : [join(pinRoot, PI_BIN)],
|
||||
extraArgs: engine.extraArgs ?? [],
|
||||
cwd: engine.cwd ?? projectRoot,
|
||||
env: engine.env ?? process.env,
|
||||
env: test ? test.env : engineEnv(engine.envKeys),
|
||||
};
|
||||
for (const k of ["preArgs", "extraArgs"]) {
|
||||
if (!Array.isArray(this.engine[k])) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${k} is not a list`);
|
||||
}
|
||||
if (typeof this.engine.command !== "string" || !this.engine.command) throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not a path");
|
||||
this.piArgs = buildPiArgs({ sessionFile: this.paths.sessionFile, extraArgs: this.engine.extraArgs });
|
||||
this.pinRoot = pinRoot;
|
||||
this.#checkSeal();
|
||||
this.launcher = launcher;
|
||||
this.verifier = verifier;
|
||||
@@ -178,7 +191,6 @@ export class Controller {
|
||||
this.barrier = barrier;
|
||||
this.now = now;
|
||||
this.T = { ...TIMEOUTS, ...timeouts };
|
||||
this.pinRoot = pinRoot;
|
||||
this.policyRevision = policyRevision;
|
||||
this.sourceRootRef = sourceRootRef;
|
||||
this.approvedMappings = approvedMappings ?? [sourceRootRef];
|
||||
@@ -232,7 +244,12 @@ export class Controller {
|
||||
if (this.barrier) await this.barrier(name, detail);
|
||||
}
|
||||
|
||||
// The seal covers the command: unless a test engine was given, the launch
|
||||
// is this Node running the pinned Pi's bin, and nothing else.
|
||||
#checkSeal() {
|
||||
if (this.engine.sealed && (this.engine.command !== process.execPath || this.engine.preArgs.length !== 1 || this.engine.preArgs[0] !== join(this.pinRoot, PI_BIN))) {
|
||||
throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not the pinned Pi");
|
||||
}
|
||||
const bad = this.engine.preArgs.find((a) => typeof a !== "string" || a === "-e" || a === "--extension" || a.startsWith("--extension="));
|
||||
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine pre-arguments carry ${bad}`);
|
||||
checkSeal(this.piArgs);
|
||||
@@ -684,9 +701,17 @@ export class Controller {
|
||||
if (!this.#checkConfirmation(r, c, op)) return refused("confirmation");
|
||||
const s = this.#startStop("force-stop", { requestId: r.id, connection: c.id, target: t });
|
||||
this.escalating = s.id;
|
||||
this.closers.add("force-stop");
|
||||
this.#admission();
|
||||
this.exec?.link?.poison("force-stop");
|
||||
try {
|
||||
this.closers.add("force-stop");
|
||||
this.#admission();
|
||||
this.exec?.link?.poison("force-stop");
|
||||
} catch (err) {
|
||||
// #handle drops `after` on a throw, so #forceStop never runs to clear
|
||||
// the flag; without this every later force stop is refused `fenced`
|
||||
// until restart (Darkwing F2 on #1507).
|
||||
if (this.escalating === s.id) this.escalating = null;
|
||||
throw err;
|
||||
}
|
||||
return { outcome: "force-stop-fenced", stop: s, after: () => this.#forceStop(s, { confirmation: cmd.confirmation }) };
|
||||
}
|
||||
if (op === "recover") return this.#recover(c, r);
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
// package's bin, and the built-in llama.cpp extension ships inside it.
|
||||
//
|
||||
// Seal: the controller builds the launch argv. It always carries
|
||||
// --no-extensions, --no-prompt-templates and --no-themes, and never an
|
||||
// --extension argument (cli/args.js; usage.md 224 and 233–236). With
|
||||
// --no-extensions Pi loads only command-line extension paths
|
||||
// (resource-loader.js 316–318), so no explicit extension loads. Under the seal
|
||||
// the Mosaic prompt in the slot is the only thing that can start a run, which
|
||||
// is the basis for attributing a run to it by order.
|
||||
// --no-extensions, --no-prompt-templates, --no-themes and --no-approve, and
|
||||
// never an --extension argument (cli/args.js; usage.md 224 and 233–236).
|
||||
// --no-approve sets the project trust override to false, so a project's
|
||||
// .pi/settings.json, SYSTEM.md, APPEND_SYSTEM.md and skills don't load even
|
||||
// when trust.json under the agent dir trusts it (main.js 574–581;
|
||||
// usage.md 126; Filbert F2 on #1522). With --no-extensions Pi loads only
|
||||
// command-line extension paths (resource-loader.js 316–318), so no explicit
|
||||
// extension loads. Under the seal the Mosaic prompt in the slot is the only
|
||||
// thing that can start a run, which is the basis for attributing a run to it
|
||||
// by order.
|
||||
//
|
||||
// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode
|
||||
// and the last --session, reads a bare word as a prompt and an `@` word as a
|
||||
@@ -28,7 +32,7 @@ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
||||
export const PI_VERSION = "0.85.1";
|
||||
export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
|
||||
export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js");
|
||||
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]);
|
||||
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes", "--no-approve"]);
|
||||
export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]);
|
||||
|
||||
export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch";
|
||||
@@ -60,7 +64,7 @@ export function buildPiArgs({ sessionFile, extraArgs = [] }) {
|
||||
return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs];
|
||||
}
|
||||
|
||||
// Refuses any argv that is not `--mode rpc`, the three --no-* flags and
|
||||
// Refuses any argv that is not `--mode rpc`, the four --no-* flags and
|
||||
// `--session <absolute path>`, in that order, followed by ENGINE_OPTIONS
|
||||
// pairs. That covers --extension in either spelling, a second --mode or
|
||||
// --session, session and output flags (--no-session, --fork, --export, ...)
|
||||
@@ -87,6 +91,24 @@ export function checkSeal(args) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The engine's environment (I3, Darkwing F3 on #1507): built from names,
|
||||
// never inherited whole. ENGINE_ENV names what Pi needs to run; a launch may
|
||||
// add provider credentials by name (`engine.envKeys`), and nothing else, so
|
||||
// NODE_OPTIONS, LD_PRELOAD and the PI_PACKAGE_DIR family can't load code
|
||||
// around the seal. Values come from the controller's own environment; an
|
||||
// unset name is left out, not set empty.
|
||||
export const ENGINE_ENV = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "PI_CODING_AGENT_DIR", "PI_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_TELEMETRY"]);
|
||||
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]{0,62}_(API_KEY|TOKEN)$/;
|
||||
|
||||
export function engineEnv(envKeys = [], source = process.env) {
|
||||
if (!Array.isArray(envKeys)) throw new ControlRefusal(UNSEALED_ENGINE, "engine.envKeys is not a list");
|
||||
const bad = envKeys.find((k) => typeof k !== "string" || !CREDENTIAL_NAME.test(k));
|
||||
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.envKeys names ${String(bad).slice(0, 80)}, which is not a provider credential (*_API_KEY or *_TOKEN)`);
|
||||
const env = {};
|
||||
for (const k of [...ENGINE_ENV, ...envKeys]) if (typeof source[k] === "string") env[k] = source[k];
|
||||
return env;
|
||||
}
|
||||
|
||||
export function argvDigest(command, args) {
|
||||
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
//
|
||||
// Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes
|
||||
// control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the
|
||||
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits.
|
||||
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits. Input after a
|
||||
// Ctrl-T or Ctrl-O waits until that finishes, in the same chunk or a later
|
||||
// one, so it is judged as if typed one key at a time.
|
||||
//
|
||||
// Engine text is shown with control characters made visible, so transcript
|
||||
// content can't drive the operator's terminal.
|
||||
@@ -26,6 +28,10 @@ import { Transcript } from "./transcript.mjs";
|
||||
export const NOT_CONTROLLER = "not admitted: controller";
|
||||
const PASTE_START = "\x1b[200~";
|
||||
const PASTE_END = "\x1b[201~";
|
||||
// Keys whose action can change who holds control. Input after one waits until
|
||||
// it finishes, so an Enter in the same chunk is judged as the keys would be
|
||||
// one at a time (Filbert F2 on #1507).
|
||||
const HOLDS = new Set(["takeover", "reload"]);
|
||||
const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" });
|
||||
|
||||
// Control characters, line and paragraph separators, bidi controls, invisible
|
||||
@@ -65,6 +71,7 @@ export class Terminal {
|
||||
this.frame = [];
|
||||
this.sent = 0;
|
||||
this.queue = Promise.resolve();
|
||||
this.held = null;
|
||||
client.on((m) => this.#onClient(m));
|
||||
this.transcript.on(() => this.render());
|
||||
}
|
||||
@@ -91,7 +98,71 @@ export class Terminal {
|
||||
}
|
||||
|
||||
// Feeds raw terminal input. Resolves when the actions it started finish.
|
||||
// While a takeover or reload is pending, input is held, not parsed.
|
||||
key(data) {
|
||||
return this.#feed(data, null);
|
||||
}
|
||||
|
||||
// Feeds terminal input like key(), but an action's error goes to the
|
||||
// status line when it happens instead of rejecting, so an unhandled
|
||||
// rejection can't end the process (Ctrl-T before the handshake throws "not
|
||||
// connected"), and a later status, such as the held Enter's "prompt:
|
||||
// admitted", is never overwritten by an earlier error (Filbert N4 on #1522).
|
||||
input(data) {
|
||||
return this.#feed(data, (err) => {
|
||||
this.status = `failed: ${err.refusal ?? err.message}`;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Held input joins the run of the takeover or reload that holds it: its
|
||||
// promise settles when that run finishes, and an error there belongs to
|
||||
// the call that started the run, not to this one.
|
||||
#feed(data, report) {
|
||||
if (this.held !== null) {
|
||||
this.held += data;
|
||||
return this.queue.catch(() => {});
|
||||
}
|
||||
const actions = this.#parse(data);
|
||||
const holds = this.held !== null;
|
||||
// A chunk runs after the one before it whether that one finished or
|
||||
// threw; the throw belongs to the call that started it, and later input
|
||||
// still runs (Filbert N1 on #1522).
|
||||
this.queue = this.queue.catch(() => {}).then(() => this.#run(actions, holds, report));
|
||||
return report ? this.queue.catch(() => {}) : this.queue;
|
||||
}
|
||||
|
||||
// Runs one chunk's actions in order. If that chunk's parse set `held`
|
||||
// (`holds`), it then parses and runs what was held behind its takeover or
|
||||
// reload. Only that run drains it: an earlier chunk's run that finishes
|
||||
// first leaves it, so the held Enter is judged after the takeover (Darkwing
|
||||
// T1 on #1522). An action that throws doesn't stop the ones after it or
|
||||
// the held input, as if each key came on its own. With `report` an error
|
||||
// is reported when it happens; without, the first one is rethrown at the
|
||||
// end.
|
||||
async #run(actions, holds, report) {
|
||||
let failure = null;
|
||||
while (actions) {
|
||||
for (const run of actions) {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
if (report) report(err);
|
||||
else failure ??= err;
|
||||
}
|
||||
}
|
||||
if (!holds) break;
|
||||
const rest = this.held;
|
||||
this.held = null;
|
||||
actions = this.#parse(rest);
|
||||
holds = this.held !== null;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
// Applies a chunk to the composer and returns the actions it starts. After
|
||||
// a HOLDS action the rest of the chunk goes to `held`.
|
||||
#parse(data) {
|
||||
const actions = [];
|
||||
let s = this.carry + data;
|
||||
this.carry = "";
|
||||
@@ -138,13 +209,17 @@ export class Terminal {
|
||||
// chunk starts the next message instead of joining this one.
|
||||
const text = this.#take();
|
||||
if (text !== null) actions.push(() => this.#send(text));
|
||||
} else if (action) actions.push(() => this.#act(action));
|
||||
else if (s[i] >= " ") this.composer += s[i];
|
||||
} else if (action) {
|
||||
actions.push(() => this.#act(action));
|
||||
if (HOLDS.has(action)) {
|
||||
this.held = s.slice(i + 1);
|
||||
break;
|
||||
}
|
||||
} else if (s[i] >= " ") this.composer += s[i];
|
||||
i += 1;
|
||||
}
|
||||
this.render();
|
||||
for (const run of actions) this.queue = this.queue.then(run);
|
||||
return this.queue;
|
||||
return actions;
|
||||
}
|
||||
|
||||
async #act(action) {
|
||||
@@ -272,7 +347,7 @@ async function main() {
|
||||
term.rows = stdout.rows || 24;
|
||||
term.render();
|
||||
});
|
||||
stdin.on("data", (c) => void term.key(c.toString("utf8")));
|
||||
stdin.on("data", (c) => void term.input(c.toString("utf8")));
|
||||
stdin.on("end", quit);
|
||||
term.render();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K18. The scope
|
||||
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K19. The scope
|
||||
// fixtures run the fake engine as a real process under ScopeLauncher, so the
|
||||
// shim, the `engine` cgroup and systemd's invocation ID are all real; they
|
||||
// skip when systemd user scopes are unavailable. K2 runs on the process-group
|
||||
// fallback. K6–K9 and K16–K18 use the in-process fake, whose force stop is a
|
||||
// fixture stand-in (see FakeLauncher). Controllers that must die run in
|
||||
// fixture stand-in (see FakeLauncher). K19 launches /bin/sleep through
|
||||
// ScopeLauncher with no controller. Controllers that must die run in
|
||||
// ctrl-child.mjs.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
@@ -11,11 +12,11 @@ import assert from "node:assert/strict";
|
||||
import { appendFileSync, chmodSync, copyFileSync, mkdirSync, readFileSync, rmdirSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { ClaimStore, FOREIGN_HOST } from "../src/claim.mjs";
|
||||
import { ClaimStore, FOREIGN_HOST, newClaimId, unitNameFor } from "../src/claim.mjs";
|
||||
import { ConversationClient } from "../src/client.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, ScopeLauncher, scopeAvailable, shimRequest, systemctlShow, systemdUnits } from "../src/cohort.mjs";
|
||||
import { Controller, ELIGIBILITY } from "../src/controller.mjs";
|
||||
import { ENGINE_PIN_MISMATCH } from "../src/pi-pin.mjs";
|
||||
import { Controller, ELIGIBILITY, TEST_ENGINE } from "../src/controller.mjs";
|
||||
import { ENGINE_ENV, ENGINE_PIN_MISMATCH, engineEnv } from "../src/pi-pin.mjs";
|
||||
import { FixtureVerifier, newId } from "../src/records.mjs";
|
||||
import { ControlClient, FakeLauncher } from "./fake-pi.mjs";
|
||||
import { FAST, REPO, assistantEntry, claimRecords, cleanupAll, controllerFor, fixture, killChildren, noUnits, reap, receiptState, spawnController, started, tick } from "./harness.mjs";
|
||||
@@ -95,7 +96,7 @@ async function live({ kind = "scope", barrier = null, verifier = new FixtureVeri
|
||||
const ctrl = new Controller({
|
||||
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
|
||||
launcher: kind === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
|
||||
engine: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") }, cwd: fx.proj },
|
||||
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") } },
|
||||
verifier, units: kind === "scope" ? systemdUnits : noUnits, timeouts: FAST, barrier,
|
||||
});
|
||||
await ctrl.start();
|
||||
@@ -714,3 +715,28 @@ test("K18: the leaf changes after eligibility: launch refused; the reservation s
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("K19: a scope launched with only the engine environment still reaches the user manager; the engine sees no other names", NEEDS_SCOPE, async () => {
|
||||
// A real launch passes engineEnv(), which names neither variable systemd-run
|
||||
// needs; ScopeLauncher.MANAGER_ENV adds them (slice 1 S5, #1522).
|
||||
if (!ScopeLauncher.MANAGER_ENV.some((k) => typeof process.env[k] === "string")) return;
|
||||
const fx = track(fixture());
|
||||
mkdirSync(fx.socketDir, { recursive: true });
|
||||
const unitName = unitNameFor(newClaimId());
|
||||
const env = engineEnv([]);
|
||||
for (const k of ScopeLauncher.MANAGER_ENV) assert.equal(k in env, false, k);
|
||||
const socketPath = join(fx.socketDir, "k19.sock");
|
||||
const proc = await new ScopeLauncher().launch({ unitName, socketPath, command: "/bin/sleep", args: ["60"], cwd: fx.proj, env });
|
||||
try {
|
||||
const names = readFileSync(`/proc/${proc.pid}/environ`, "utf8").split("\0").filter(Boolean).map((e) => e.slice(0, e.indexOf("=")));
|
||||
// systemd-run sets INVOCATION_ID; the shim's /bin/sh sets PWD, and SHLVL
|
||||
// and _ when it is bash. None comes from the controller's environment.
|
||||
const set = ["INVOCATION_ID", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
for (const k of names) assert.ok(ENGINE_ENV.includes(k) || ScopeLauncher.MANAGER_ENV.includes(k) || set.includes(k), k);
|
||||
for (const k of ScopeLauncher.MANAGER_ENV) if (typeof process.env[k] === "string") assert.ok(names.includes(k), k);
|
||||
} finally {
|
||||
assert.equal((await shimRequest(socketPath, "kill", { timeoutMs: 5000 }, 8000)).ok, true);
|
||||
assert.equal((await shimRequest(socketPath, "release")).ok, true);
|
||||
await proc.exited;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { join } from "node:path";
|
||||
import { Controller } from "../src/controller.mjs";
|
||||
import { Controller, TEST_ENGINE } from "../src/controller.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, ScopeLauncher, systemdUnits } from "../src/cohort.mjs";
|
||||
import { FixtureVerifier } from "../src/records.mjs";
|
||||
import { defaultHost } from "../src/claim.mjs";
|
||||
@@ -75,7 +75,7 @@ try {
|
||||
ctrl = new Controller({
|
||||
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: cfg.socketDir ?? fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
|
||||
launcher: cfg.launcher === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
|
||||
engine: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) }, cwd: fx.proj },
|
||||
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) } },
|
||||
verifier: cfg.verifier === false ? null : new FixtureVerifier({ authorities: [AUTHORITY] }),
|
||||
units: cfg.units === "absent" ? { lookup: async () => ({ state: "absent" }) } : systemdUnits,
|
||||
host, barrier, timeouts: cfg.timeouts,
|
||||
|
||||
@@ -486,8 +486,8 @@ export class FakeLauncher {
|
||||
this.launches = [];
|
||||
}
|
||||
|
||||
async launch({ unitName, command, args, cwd }) {
|
||||
this.launches.push({ unitName, command, args, cwd });
|
||||
async launch({ unitName, command, args, cwd, env }) {
|
||||
this.launches.push({ unitName, command, args, cwd, env });
|
||||
const toEngine = new PassThrough();
|
||||
const fromEngine = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
|
||||
@@ -584,6 +584,196 @@ test("terminal: a paste-start marker split right after its ESC still opens the p
|
||||
assert.equal(term.composer, "x\n");
|
||||
});
|
||||
|
||||
// An observer stub whose takeover (or reconnect) waits on a gate, then makes
|
||||
// this connection the controller and pushes the binding before it resolves,
|
||||
// as the controller does.
|
||||
function takeoverStub({ grant = true, closed = false } = {}) {
|
||||
const { stub, sent } = promptStub();
|
||||
let listener = () => {};
|
||||
let open;
|
||||
const gate = new Promise((r) => (open = r));
|
||||
const become = () => {
|
||||
stub.isController = true;
|
||||
stub.binding = { state: "active", controllerConnection: "conn-1" };
|
||||
listener({ type: "push", kind: "binding" });
|
||||
};
|
||||
Object.assign(stub, {
|
||||
closed, isController: false, binding: { state: "active", controllerConnection: "conn-2" },
|
||||
on: (fn) => (listener = fn),
|
||||
takeover: async () => {
|
||||
await gate;
|
||||
if (!grant) return { outcome: "refused:controller", refusal: "controller" };
|
||||
become();
|
||||
return { outcome: "transferred", refusal: null };
|
||||
},
|
||||
connect: async () => (await gate, (stub.closed = false), become()),
|
||||
});
|
||||
return { stub, sent, open };
|
||||
}
|
||||
|
||||
test("terminal: Ctrl-T then Enter in one chunk is judged after the takeover, as if typed one key at a time (Filbert F2, #1507)", async () => {
|
||||
{
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], "text typed after the takeover is sent");
|
||||
assert.equal(term.status, "prompt: admitted");
|
||||
}
|
||||
{
|
||||
// §4: text typed before the takeover is cleared by the transfer, so the
|
||||
// Enter after it sends nothing.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("hi\x14\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(term.composer, "");
|
||||
}
|
||||
{
|
||||
// A refused takeover leaves an observer: the Enter is refused and the
|
||||
// text stays for the operator.
|
||||
const { stub, sent, open } = takeoverStub({ grant: false });
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(term.composer, "hi");
|
||||
assert.equal(term.status, NOT_CONTROLLER);
|
||||
}
|
||||
{
|
||||
// Input in later chunks waits behind the pending takeover and keeps its
|
||||
// order, including a paste split across the hold.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const term = new Terminal({ client: stub });
|
||||
const first = term.key("\x14a");
|
||||
const second = term.key(`b${PASTE_START}c\rd`);
|
||||
const third = term.key(`${PASTE_END}e\rf`);
|
||||
assert.equal(term.composer, "", "nothing is parsed while the takeover is pending");
|
||||
open();
|
||||
await Promise.all([first, second, third]);
|
||||
assert.deepEqual(sent, ["abc\rde"]);
|
||||
assert.equal(term.composer, "f");
|
||||
}
|
||||
{
|
||||
// Ctrl-O reconnecting a closed client holds the same way.
|
||||
const { stub, sent, open } = takeoverStub({ closed: true });
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x0fhi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal: input held behind Ctrl-T waits for that takeover while an earlier action is still pending (Darkwing T1 on #1522)", async () => {
|
||||
// An observer presses Ctrl-G (a slow interrupt), then Ctrl-T and "hi"
|
||||
// Enter. The interrupt finishing first must not release the text held
|
||||
// behind the takeover.
|
||||
for (const chunks of [["\x07", "\x14", "hi\r"], ["\x07", "\x14hi\r"], ["\x07\x14hi\r"]]) {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const log = [];
|
||||
let openInterrupt;
|
||||
const interruptGate = new Promise((r) => (openInterrupt = r));
|
||||
stub.interrupt = async () => {
|
||||
log.push("interrupt start");
|
||||
await interruptGate;
|
||||
log.push("interrupt end");
|
||||
return { outcome: "refused:controller", refusal: "controller" };
|
||||
};
|
||||
const takeover = stub.takeover;
|
||||
stub.takeover = async () => (log.push("takeover start"), await takeover(), log.push("takeover end"), { outcome: "transferred", refusal: null });
|
||||
const prompt = stub.prompt;
|
||||
stub.prompt = async (t) => (log.push(`prompt ${t}`), prompt(t));
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = Promise.all(chunks.map((c) => term.key(c)));
|
||||
openInterrupt();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(sent, [], `${JSON.stringify(chunks)}: nothing is sent before the takeover finishes`);
|
||||
assert.notEqual(term.held, null, `${JSON.stringify(chunks)}: the text is still held`);
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
|
||||
assert.equal(term.composer, "");
|
||||
assert.equal(term.status, "prompt: admitted");
|
||||
assert.deepEqual(log, ["interrupt start", "interrupt end", "takeover start", "takeover end", "prompt hi"], JSON.stringify(chunks));
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal: an action that throws still releases the input held behind it, in order, then rethrows", async () => {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.takeover = async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = term.key("\x14x\r");
|
||||
open();
|
||||
await assert.rejects(done, /boom/);
|
||||
assert.equal(term.held, null);
|
||||
assert.equal(term.composer, "x", "the held text was parsed; the Enter was refused as an observer");
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("terminal: after an action throws, later input still runs; input() puts the error in the status line (Filbert N1 on #1522)", async () => {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
const takeover = stub.takeover;
|
||||
let calls = 0;
|
||||
stub.takeover = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("not connected");
|
||||
return takeover();
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
await assert.rejects(term.key("\x14"), /not connected/);
|
||||
// The queue is rejected now; the next chunk still runs its actions.
|
||||
const done = term.key("\x14hi\r");
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
assert.equal(term.composer, "");
|
||||
// input() never rejects: the error lands in the status line.
|
||||
stub.takeover = async () => {
|
||||
throw new Error("not connected");
|
||||
};
|
||||
await term.input("\x14");
|
||||
assert.equal(term.status, "failed: not connected");
|
||||
});
|
||||
|
||||
test("terminal: input() reports an error when it happens, so it never overwrites a later status; held input's promise doesn't carry the holder's error (Filbert N4 on #1522)", async () => {
|
||||
// Ctrl-G throws. The takeover after it still runs, as it would if typed on
|
||||
// its own, and the held Enter is admitted: the status ends on the
|
||||
// admission, not the old error.
|
||||
for (const chunks of [["\x07\x14hi\r"], ["\x07\x14", "hi\r"]]) {
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.interrupt = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const done = Promise.all(chunks.map((c) => term.input(c)));
|
||||
open();
|
||||
await done;
|
||||
assert.deepEqual(sent, ["hi"], JSON.stringify(chunks));
|
||||
assert.equal(term.status, "prompt: admitted", JSON.stringify(chunks));
|
||||
}
|
||||
// Through key() the error belongs to the call that started the run; the
|
||||
// held chunk's call resolves when that run finishes.
|
||||
const { stub, sent, open } = takeoverStub();
|
||||
stub.interrupt = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const term = new Terminal({ client: stub });
|
||||
const holder = term.key("\x07\x14");
|
||||
const held = term.key("hi\r");
|
||||
open();
|
||||
await assert.rejects(holder, /boom/);
|
||||
await held;
|
||||
assert.deepEqual(sent, ["hi"]);
|
||||
});
|
||||
|
||||
test("terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line", () => {
|
||||
assert.equal(visible("a\u061cb\u200bc\u2060d\ufeffe\u{e0041}f"), "a<U+061C>b<U+200B>c<U+2060>d<U+FEFF>e<U+E0041>f");
|
||||
assert.equal(visible("\u{1F469}\u200d\u{1F4BB}"), "\u{1F469}\u200d\u{1F4BB}", "ZWJ sequences pass");
|
||||
|
||||
@@ -563,6 +563,30 @@ test("H10: a second force stop while the first escalation runs refuses fenced; o
|
||||
}
|
||||
});
|
||||
|
||||
test("a force stop whose fence throws leaves no escalation flag behind, so the next force stop runs (Darkwing F2, #1507)", async () => {
|
||||
const h = await started();
|
||||
try {
|
||||
const link = h.ctrl.exec.link;
|
||||
const poison = link.poison;
|
||||
link.poison = () => {
|
||||
throw new Error("poison failed");
|
||||
};
|
||||
const failed = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
|
||||
assert.equal(failed.outcome, "error", JSON.stringify(failed));
|
||||
assert.equal(h.ctrl.escalating, null, "the flag is cleared on the throw");
|
||||
assert.equal(h.launcher.stops ?? 0, 0, "no escalation ran");
|
||||
link.poison = poison;
|
||||
await synced(h, h.client);
|
||||
const next = await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") });
|
||||
assert.equal(next.outcome, "force-stop-fenced", JSON.stringify(next));
|
||||
await waitState(h, "stopped");
|
||||
assert.equal(h.launcher.stops, 1);
|
||||
assert.equal(h.ctrl.escalating, null);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("H17: a confirmation reused, answered from another connection, or used after the stop changed is refused", async () => {
|
||||
// Reused while its force stop is still running.
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -26,11 +26,11 @@ after(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
const COMMANDS = ["get_state", "get_commands", "clear_queue", "abort", "get_tree"];
|
||||
const exchanges = {};
|
||||
|
||||
function session(name, entries) {
|
||||
const dir = join(scratch, "proj", ".pi", "state", "smoke", "sessions");
|
||||
function session(name, entries, project = "proj") {
|
||||
const dir = join(scratch, project, ".pi", "state", "smoke", "sessions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, name);
|
||||
writeFileSync(file, [header(join(scratch, "proj")), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
|
||||
writeFileSync(file, [header(join(scratch, project)), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -82,13 +82,17 @@ async function converse(input, output, { onExit = null } = {}) {
|
||||
return { lines, responses };
|
||||
}
|
||||
|
||||
async function realPi(tag, sessionFile) {
|
||||
// `trusted` writes the agent dir's trust.json first, marking that project
|
||||
// trusted the way an operator's `~/.pi/agent/trust.json` can; `after` is
|
||||
// argv appended past the seal, which checkSeal would refuse.
|
||||
async function realPi(tag, sessionFile, { project = "proj", trusted = null, after = [] } = {}) {
|
||||
const { home, agent, env } = scratchEnv(tag);
|
||||
assert.equal(existsSync(join(home, ".pi", "agent", "auth.json")), false);
|
||||
assert.deepEqual(readdirSync(agent), [], "the agent dir starts empty");
|
||||
if (trusted) writeFileSync(join(agent, "trust.json"), JSON.stringify({ [realpathSync(join(scratch, trusted))]: true }));
|
||||
const args = buildPiArgs({ sessionFile });
|
||||
checkSeal(args);
|
||||
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args], { cwd: join(scratch, "proj"), env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args, ...after], { cwd: join(scratch, project), env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
pi.stderr.on("data", (c) => (stderr += c));
|
||||
const exited = new Promise((r) => pi.on("exit", (code, signal) => r({ code, signal })));
|
||||
@@ -161,6 +165,24 @@ test("pinned Pi, sealed and without credentials, answers the controller's comman
|
||||
}
|
||||
});
|
||||
|
||||
test("sealed, pinned Pi ignores a trusted project's .pi resources; --approve past the seal would load them, and checkSeal refuses it (Filbert F2 on #1522)", async () => {
|
||||
const skill = join(scratch, "trusted", ".pi", "skills", "probe");
|
||||
mkdirSync(skill, { recursive: true });
|
||||
writeFileSync(join(skill, "SKILL.md"), "---\nname: probe\ndescription: A project skill that only a trusted load offers.\n---\nprobe\n");
|
||||
const entries = [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
|
||||
const offered = (side) => (side.responses.get_commands.data?.commands ?? []).map((c) => c.name);
|
||||
const sealed = await realPi("trust-sealed", session("trust-sealed.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted" });
|
||||
assert.deepEqual(offered(sealed), ["llama"], "the project skill is not loaded under the seal");
|
||||
// The control: the same trusted project, with --approve after the seal (Pi
|
||||
// keeps the last of --approve and --no-approve), loads the skill, so the
|
||||
// assertion above can see a load.
|
||||
const approved = await realPi("trust-approved", session("trust-approved.jsonl", entries, "trusted"), { project: "trusted", trusted: "trusted", after: ["--approve"] });
|
||||
assert.ok(offered(approved).includes("skill:probe"), `with --approve: ${offered(approved)}`);
|
||||
for (const flag of ["--approve", "-a"]) {
|
||||
assert.throws(() => checkSeal([...buildPiArgs({ sessionFile: "/s.jsonl" }), flag]), /not one of/, flag);
|
||||
}
|
||||
});
|
||||
|
||||
test("pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed)", async () => {
|
||||
const entries = [userEntry("a1b2c3d4", null, "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
|
||||
const realFile = session("bare-real.jsonl", entries);
|
||||
|
||||
@@ -6,10 +6,10 @@ import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { join } from "node:path";
|
||||
import { Controller, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
|
||||
import { Controller, TEST_ENGINE, REPO_ROOT, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
|
||||
import { AUTHORITY } from "../src/cohort.mjs";
|
||||
import { FixtureVerifier, sha256 } from "../src/records.mjs";
|
||||
import { SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
|
||||
import { ENGINE_ENV, PI_BIN, SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
|
||||
import { LineSplitter, encodeLine, parseLine } from "../src/framing.mjs";
|
||||
import { BUSY_ERROR, FakeLauncher, FakePi } from "./fake-pi.mjs";
|
||||
import { fixture, started, receiptState, outcomeUnknownPush, sessionText, cleanupAll, tick, noUnits, FAST } from "./harness.mjs";
|
||||
@@ -970,7 +970,7 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
|
||||
assert.throws(() => checkSeal(args), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(args));
|
||||
}
|
||||
assert.equal(checkSeal(["--mode", "rpc", ...SEAL_FLAGS, "--session", fx.sessionFile, "--model", "m", "--provider", "p", "--thinking", "off"]), true);
|
||||
// The argv a real bind launches carries all three and no --extension.
|
||||
// The argv a real bind launches carries every seal flag and no --extension.
|
||||
const h = await started({ fx: fixture() });
|
||||
try {
|
||||
const args = h.launcher.launches[0].args;
|
||||
@@ -981,3 +981,63 @@ test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a seco
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("N24b: the seal covers the engine command and environment: config can't name either, the env is built from names, and a mutated command is refused at bind", async () => {
|
||||
const fx = fixture();
|
||||
const make = (extra) => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher: new FakeLauncher(), units: noUnits, timeouts: FAST, ...extra });
|
||||
// The plain engine option takes extraArgs, cwd and envKeys; the command,
|
||||
// pre-arguments and environment are the controller's (I3, #1507).
|
||||
for (const engine of [{ command: "/bin/sh" }, { preArgs: [join(REPO_ROOT, PI_BIN)] }, { command: process.execPath, preArgs: [join(REPO_ROOT, PI_BIN), "--extension", "x"] }, { env: {} }, { env: process.env }, null, [], "pi"]) {
|
||||
assert.throws(() => make({ engine }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(engine));
|
||||
}
|
||||
// A configuration is JSON, which can't carry the symbol-keyed test engine.
|
||||
const parsed = JSON.parse(JSON.stringify({ engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: "/bin/sh", preArgs: [], env: {} } }));
|
||||
assert.equal(Object.getOwnPropertySymbols(parsed).length, 0);
|
||||
// envKeys may name provider credentials only.
|
||||
for (const envKeys of [["NODE_OPTIONS"], ["LD_PRELOAD"], ["PI_PACKAGE_DIR"], ["BASH_ENV"], ["ZAI_API_KEY", "PATH"], ["zai_api_key"], ["_API_KEY"], [3], "ZAI_API_KEY"]) {
|
||||
assert.throws(() => make({ engine: { envKeys } }), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(envKeys));
|
||||
}
|
||||
// The test engine is still sealed on its arguments.
|
||||
assert.throws(() => make({ [TEST_ENGINE]: { command: process.execPath, preArgs: ["fake.mjs", "-e", "x"], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.throws(() => make({ [TEST_ENGINE]: { command: "", preArgs: [], env: {} } }), (e) => e.code === UNSEALED_ENGINE);
|
||||
|
||||
// The launch: this Node, the pinned bin, and an environment of named keys
|
||||
// only, whatever the controller's own environment holds.
|
||||
const planted = { NODE_OPTIONS: "--require /tmp/x.cjs", LD_PRELOAD: "/tmp/x.so", PI_PACKAGE_DIR: "/tmp/pkg", ZAI_API_KEY: "zai-test-value", OTHER_API_KEY: "other-test-value" };
|
||||
const saved = Object.fromEntries(Object.keys(planted).map((k) => [k, process.env[k]]));
|
||||
Object.assign(process.env, planted);
|
||||
let h;
|
||||
try {
|
||||
h = await started({ fx: fixture(), engine: { envKeys: ["ZAI_API_KEY"] } });
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
||||
}
|
||||
try {
|
||||
const l = h.launcher.launches[0];
|
||||
assert.equal(l.command, process.execPath);
|
||||
assert.equal(l.args[0], join(REPO_ROOT, PI_BIN));
|
||||
assert.equal(l.env.ZAI_API_KEY, "zai-test-value");
|
||||
for (const k of Object.keys(l.env)) assert.ok(ENGINE_ENV.includes(k) || k === "ZAI_API_KEY", k);
|
||||
for (const k of ["NODE_OPTIONS", "LD_PRELOAD", "PI_PACKAGE_DIR", "OTHER_API_KEY"]) assert.equal(k in l.env, false, k);
|
||||
if (process.env.PATH) assert.equal(l.env.PATH, process.env.PATH);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
|
||||
// A command changed after construction is refused at bind; nothing launches.
|
||||
const fx2 = fixture();
|
||||
const launcher = new FakeLauncher();
|
||||
const ctrl = new Controller({ fixtureRoot: fx2.base, claimRoot: fx2.claimRoot, socketDir: fx2.socketDir, sessionFile: fx2.sessionFile, seat: fx2.seat, launcher, units: noUnits, timeouts: FAST });
|
||||
// Closed in finally: a start that wrongly succeeds holds the socket open.
|
||||
try {
|
||||
ctrl.engine.command = "/bin/sh";
|
||||
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.equal(launcher.launches.length, 0);
|
||||
ctrl.engine.command = process.execPath;
|
||||
ctrl.engine.preArgs = [join(fx2.base, "cli.js")];
|
||||
await assert.rejects(ctrl.start(), (e) => e.code === UNSEALED_ENGINE);
|
||||
assert.equal(launcher.launches.length, 0);
|
||||
} finally {
|
||||
await ctrl.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ node packages/webui/src/cli.mjs serve
|
||||
|
||||
Open http://127.0.0.1:7330/. Optional `--port N` and
|
||||
`--board http://127.0.0.1:7331` select another port or loopback board origin.
|
||||
`--business ID` picks the business the bus views read; without it they read
|
||||
the running bus host's business (`mosaic bus start <business>`).
|
||||
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
|
||||
installation, account, authentication or deployment is added.
|
||||
|
||||
@@ -58,6 +60,40 @@ A pending send stays disabled across refresh; new text typed during a send is
|
||||
not cleared by the earlier send's success. If the proxy loses the response,
|
||||
delivery may be unknown: inspect the seat before sending again.
|
||||
|
||||
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
|
||||
|
||||
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
|
||||
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
|
||||
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>` and
|
||||
`#/trail/decision/<id>`, with `?kind=` filters on a trail. The design
|
||||
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
|
||||
- Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`,
|
||||
lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes. A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
business, as the Console does by default. From a Console started with
|
||||
`--business` for another business the copied command refuses (no bus host,
|
||||
or no such decision) rather than answering elsewhere.
|
||||
- What no Q1 verb returns is labelled where it would show, for example
|
||||
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
since the list has no trail. The task page reads the trail and tells a task
|
||||
the stack never created from one changed in Vikunja after the stack wrote it.
|
||||
A stale poll read is shown in the snapshots and never as current.
|
||||
- All bus- and agent-authored text is rendered inert: controls show as control
|
||||
pictures or `[U+XXXX]`, and Markdown stays as source.
|
||||
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
|
||||
open sections and the copy status survive a refresh.
|
||||
- A failed read keeps the last good answer for that read and says it is old,
|
||||
with the time it was read. A page never read shows the failure instead,
|
||||
titled "Bus refused the read" (403: the bus refused the Console's read, for
|
||||
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
|
||||
config, or no bus host and no `--business`) or "The read failed".
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
@@ -68,6 +104,24 @@ configured board URL for the page's error message. No scanner, registration,
|
||||
session reader or transport is implemented here. The session reader is
|
||||
`packages/conversation`, served by the board.
|
||||
|
||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and
|
||||
`/api/bus/trail?subject=<id>` are the bus reads (`src/bus.mjs`). Any other
|
||||
method is 405. The subject must match the broker's identifier rule, else 400.
|
||||
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
|
||||
a child process, so a slow read doesn't stall the server. The bus checks
|
||||
that child's ancestry, which ends at the Console process; it never sees the
|
||||
browser or any other client of the port. So while the Console runs,
|
||||
anything that can connect to its port (7330 by default) reads what a reader
|
||||
capability reads ("Human and reader paths" in `packages/bus/README.md`):
|
||||
the inbox, tasks, agents and trails. That includes a T3 seat using `curl`
|
||||
and a managed S6 session, since S6 doesn't confine the network. No route
|
||||
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
|
||||
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }`
|
||||
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
|
||||
that can't be used. The bus needs the system config
|
||||
(`~/.config/mosaic-dev/config.json`); without it the board still serves and
|
||||
the bus routes answer `not-configured`.
|
||||
|
||||
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
|
||||
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
|
||||
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
|
||||
@@ -83,6 +137,7 @@ node --test packages/webui/tests/
|
||||
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
|
||||
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
|
||||
```
|
||||
|
||||
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
|
||||
@@ -106,6 +161,13 @@ the return flow: a send from the view, a tool call and a delayed result while a
|
||||
draft is typed, a peer message, a 4.5-million-character answer split into
|
||||
continuation parts, and a relaunch mid-turn.
|
||||
|
||||
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
|
||||
views against an in-process broker read through its reader session, so every
|
||||
row has the broker's own shape. `humanCall` runs against a stub CLI script.
|
||||
They cover each view, the gap labels, inert hostile text, Copy, the stale
|
||||
fallback and each failure title, and that no route writes. They never start
|
||||
the human CLI against a live bus.
|
||||
|
||||
No root CI workflow is configured for this package. These local tests are not a
|
||||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
|
||||
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
|
||||
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
||||
// Only the four verbs of the Q1 module (lead decision 56) are reachable:
|
||||
// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a
|
||||
// server started inside an agent run gets `human-required` from the broker.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
|
||||
export class BusReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
}
|
||||
|
||||
// The broker's identifier rule (packages/bus/src/broker.mjs `id`), checked
|
||||
// here so a bad subject is a 400 and never reaches the bus.
|
||||
export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length <= 160 && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(s);
|
||||
|
||||
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busReader({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
const call = async (verb, args) => {
|
||||
let id = business;
|
||||
if (!id) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
id = state.business;
|
||||
}
|
||||
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
|
||||
};
|
||||
return views({ call });
|
||||
}
|
||||
|
||||
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
|
||||
// bus to ask, or no answer; 502: an answer that can't be used.
|
||||
const STATUS = {
|
||||
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
|
||||
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
|
||||
'invalid-request': 400,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { startServer, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busReader } from './bus.mjs';
|
||||
import { loadSystem, socketPath } from '../../cli/src/config.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
@@ -13,10 +16,22 @@ try {
|
||||
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
|
||||
options.port = Number(value);
|
||||
} else if (flag === '--board') options.board = value;
|
||||
else if (flag === '--business') {
|
||||
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
|
||||
options.business = value;
|
||||
}
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const server = await startServer(options);
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\nCtrl-C stops this server.`);
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
let bus = null, busLine;
|
||||
try {
|
||||
const { dataRoot } = loadSystem();
|
||||
bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) { busLine = `Bus: not configured (${err.message})`; }
|
||||
const server = await startServer({ board: options.board, port: options.port, bus });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Slice 1 S5 (#1522): inbox, tasks, agents and trail (bus.js), on top of the Console and
|
||||
live styles. Nothing here changes those files. On a bus route (body.on-bus) the board's
|
||||
own parts are hidden and the inspector column is dropped; the board itself is untouched. */
|
||||
#board-tree{display:contents}
|
||||
body.on-bus #board-tree,body.on-bus #board-head,body.on-bus #error,body.on-bus #conversation,body.on-bus #board-view,body.on-bus #inspector{display:none}
|
||||
body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px minmax(0,1fr)}
|
||||
@media (min-width:1900px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:280px minmax(0,1fr)}}
|
||||
@media (min-width:2560px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:320px minmax(0,1fr)}}
|
||||
@media (max-width:959px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:minmax(0,1fr)}}
|
||||
.s1-sections .count{margin-left:auto}.s1-auth{overflow-wrap:anywhere}
|
||||
.s1-sections{display:grid;gap:4px;list-style:none;margin:0;padding:0}.s1-section{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:7px 8px;border-radius:6px;color:var(--text);text-decoration:none;font-weight:600;border:1px solid transparent;min-height:38px}
|
||||
.s1-section:hover{background:var(--raised)}.s1-section[aria-current=page]{background:var(--raised);border-color:var(--border);color:var(--action)}
|
||||
/* The h1 takes focus after navigation so screen readers land on the new page; it is not a control, so no ring. */
|
||||
.content h1{overflow-wrap:anywhere}.content h1:focus{outline:none}
|
||||
.page-head>div{min-width:0}#bus-status{overflow-wrap:anywhere}
|
||||
/* Inbox */
|
||||
.s1-decs{grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
|
||||
.s1-dec:not(.is-attn){border-left-color:var(--border);background:var(--surface)}
|
||||
.s1-dec-open{font-weight:600;color:var(--action);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.s1-badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 4px}.s1-dec .small{margin:4px 0 0;overflow-wrap:anywhere}
|
||||
.s1-q{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 12px;max-width:80ch}
|
||||
.s1-opts{display:grid;gap:10px;padding-left:0;list-style:none;margin:0 0 8px}.s1-opt{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--surface)}
|
||||
.s1-opt p{margin:0;overflow-wrap:anywhere}.s1-key{font-family:var(--mono);margin-right:6px}
|
||||
.s1-cmd{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}.s1-cmd code{flex:1 1 260px;min-width:0;overflow-wrap:anywhere;font-size:.82rem;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);user-select:all}
|
||||
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
|
||||
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
|
||||
/* Tables */
|
||||
table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
|
||||
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
|
||||
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
|
||||
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.s1-stale{margin-bottom:16px}.s1-stale p{margin:0}
|
||||
/* Trail */
|
||||
.s1-trail{list-style:none;margin:0 0 12px;padding:0;border-left:2px solid var(--line)}
|
||||
.s1-trow{display:grid;grid-template-columns:4.2em minmax(9em,13em) minmax(0,1fr);gap:4px 12px;padding:8px 0 8px 12px;border-bottom:1px solid var(--line);position:relative}
|
||||
.s1-trow::before{content:'';position:absolute;left:-6px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--muted)}
|
||||
.s1-g-request::before{background:var(--action)}.s1-g-decision::before{background:var(--warning)}.s1-g-launch::before{background:var(--accent)}.s1-g-review::before{background:var(--success)}
|
||||
.s1-twhen{color:var(--muted);font-variant-numeric:tabular-nums}.s1-tkind{font-family:var(--mono);font-size:.8rem;overflow-wrap:anywhere}.s1-ttext{min-width:0}.s1-ttext p{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
|
||||
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
|
||||
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
|
||||
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
|
||||
@@ -0,0 +1,456 @@
|
||||
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
|
||||
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
|
||||
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
|
||||
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
|
||||
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// The CHAT-02 rules: C0 controls show as control pictures, C1 and bidi controls as
|
||||
// [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
|
||||
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f--]/g;
|
||||
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
|
||||
const txt = v => esc(inert(v)); // every agent-, tracker- or bus-authored string goes through this
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
const enc = encodeURIComponent;
|
||||
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/; // the broker's identifier rule
|
||||
const TASK = /^vikunja:(\d+)\/(\d+)$/;
|
||||
const CLOSE = ['resolved', 'withdrawn', 'expired'];
|
||||
|
||||
// ---------------------------------------------------------------- reads
|
||||
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
|
||||
// can show what was read before, labelled as such.
|
||||
const last = new Map();
|
||||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||||
async function fetchRead(verb, subject) {
|
||||
let res, body;
|
||||
try {
|
||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
|
||||
body = await res.json();
|
||||
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { rows: body.rows, at: body.at };
|
||||
}
|
||||
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
||||
function reader(live) {
|
||||
const used = [];
|
||||
const get = async (verb, subject) => {
|
||||
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
||||
let r;
|
||||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||
used.push(r.at);
|
||||
return r.rows;
|
||||
};
|
||||
get.oldest = () => used.filter(Boolean).sort()[0] || null;
|
||||
return get;
|
||||
}
|
||||
const of = (rows, table) => rows.filter(r => r.table === table);
|
||||
|
||||
// One decision from its trail. An open human decision's inbox row carries the
|
||||
// authorization context; otherwise it comes from the trail's decision.raise event,
|
||||
// the rule the broker's own decision view uses.
|
||||
async function decision(get, id, row = null) {
|
||||
const tr = await get('trail', id), d = of(tr, 'decisions').find(x => x.id === id);
|
||||
if (!d) return null;
|
||||
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
|
||||
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body?.operation === 'decision.raise' && e.body?.decision === id);
|
||||
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
|
||||
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
|
||||
return {
|
||||
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
|
||||
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
|
||||
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
|
||||
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
|
||||
seen: ev.filter(x => x.op === 'seen'), closed, dm: dm ? { at: dm.at } : null, supersedes: d.supersedes, trail: tr,
|
||||
};
|
||||
}
|
||||
// Earlier versions through `supersedes`, newest first, one trail read each, at most ten.
|
||||
async function chain(get, d) {
|
||||
const out = [];
|
||||
for (let x = d, n = 0; x?.supersedes && n < 10; n++) { x = await decision(get, x.supersedes); if (x) out.push(x); }
|
||||
return out;
|
||||
}
|
||||
// A task from its task_current row; the trail adds what only events carry.
|
||||
function task(row, tr = null) {
|
||||
const ref = row.task_ref, f = row.fields || {}, [, project, id] = ref.match(TASK) || [, null, null];
|
||||
const evs = tr ? of(tr, 'events').filter(e => e.subject === ref) : [], snaps = tr ? of(tr, 'task_snapshots') : [];
|
||||
const shape = f.gone ? snaps.filter(s => !s.fields?.gone).at(-1)?.fields || {} : f;
|
||||
const created = evs.find(e => e.kind === 'task.created');
|
||||
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
|
||||
// task_current already skips stale reads, so a current row from a poll is a change the
|
||||
// stack didn't make: to a task the stack wrote ('changed') or to one it never wrote ('foreign').
|
||||
const external = !f.gone && row.source === 'poll' ? { kind: tr ? (snaps.some(s => s.source === 'self') ? 'changed' : 'foreign') : null, via: row.via, readAt: row.read_at, changed: Array.isArray(ext?.body?.changed) ? ext.body.changed : [], comments: ext?.body?.comments || 0 } : null;
|
||||
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
|
||||
return {
|
||||
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
|
||||
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
|
||||
requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
|
||||
due: shape.due_date ?? null, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
|
||||
changedAt: row.at, seq: row.seq, external,
|
||||
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
|
||||
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
|
||||
decisions: tr ? of(tr, 'decisions').filter(d => d.task_ref === ref) : [],
|
||||
// Rows after the current one are reads task_current skipped (lead decision 59).
|
||||
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, fields: s.fields || {} })),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- trail lines
|
||||
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
|
||||
const bucketText = f => f?.gone ? `gone (${f.gone})` : `bucket ${f?.bucket}`;
|
||||
const short = id => String(id ?? '').slice(0, 8);
|
||||
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
||||
function line(r, ctx) {
|
||||
const out = { at: r.at, actor: null, by: 'broker', decision: null, note: null };
|
||||
if (r.table === 'events') {
|
||||
const b = r.body && typeof r.body === 'object' ? r.body : {};
|
||||
out.kind = r.kind; out.decision = typeof b.decision === 'string' ? b.decision : null;
|
||||
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
|
||||
const map = {
|
||||
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
|
||||
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run}${b.reason ? ` (${b.reason}${b.exit !== undefined ? `, exit ${b.exit}` : ''})` : ''}`,
|
||||
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `The human chose “${b.choice}” from the CLI` : `The human ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
|
||||
: b.kind === 'instruction' ? 'Request from the human, recorded from the CLI' : b.kind === 'admin' ? 'Admin command from the human, CLI' : 'Input from the human',
|
||||
'task.created': () => `Created for ${b.requirement}`,
|
||||
'task.assigned': () => `Assigned to ${b.role}`,
|
||||
'task.state': () => `Moved ${b.previous ? `from ${b.previous} ` : ''}to ${b.state}${b.percent_done !== undefined ? `, ${b.percent_done}% done` : ''}${b.comment ? ', with a comment in Vikunja' : ''}`,
|
||||
'task.closed': () => `Closed with verdict ${b.verdict}`,
|
||||
'task.changed.external': () => {
|
||||
const changed = Array.isArray(b.changed) ? b.changed : [], comments = b.comments ? plural(b.comments, 'new comment') : '';
|
||||
if (b.previous === null) return 'Read from Vikunja for the first time: the stack did not create it';
|
||||
return changed.length ? `Changed in Vikunja: ${changed.join(', ')}${comments ? `, and ${comments}` : ''}` : comments ? `${comments[0].toUpperCase()}${comments.slice(1)} in Vikunja` : 'Read from Vikunja';
|
||||
},
|
||||
'task.conflict': () => `Write refused (${b.verb}): Vikunja changed after the stack read it (expected ${short(b.expected)}, found ${short(b.actual)})`,
|
||||
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
|
||||
'review.requested': () => 'Review requested',
|
||||
'review.verdict': () => `Review verdict: ${b.verdict ?? 'not recorded'}`,
|
||||
'action.refused': () => `Refused: ${b.code}`,
|
||||
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}`,
|
||||
'launch.revoked': () => 'Launching revoked from the CLI',
|
||||
'launch.restored': () => 'Launching restored from the CLI',
|
||||
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires ?? 'soon'}`,
|
||||
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
|
||||
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
|
||||
};
|
||||
out.text = (map[r.kind] || (() => r.kind))();
|
||||
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
|
||||
if (r.kind === 'human.input' && b.kind === 'answer') out.groupAs = 'decision'; // an answer, not a request
|
||||
} else if (r.table === 'messages') {
|
||||
out.kind = `message.${String(r.class).toLowerCase()}`; out.decision = r.decision;
|
||||
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
|
||||
out.text = `${r.from_role === 'human' ? 'The human' : r.from_role} to ${r.to_role}: ${r.body}`;
|
||||
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'deliveries') {
|
||||
const m = ctx.messages[r.message];
|
||||
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
|
||||
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
|
||||
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'decisions') {
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||||
} else if (r.table === 'task_snapshots') {
|
||||
out.kind = `snapshot.${r.source}`;
|
||||
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
|
||||
out.text = r.source === 'self' ? `Written: ${bucketText(r.fields)}` : `Read from Vikunja (${r.via} read sent ${String(r.read_at).slice(11, 19)}): ${bucketText(r.fields)}`;
|
||||
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
|
||||
} else if (r.table === 'role_claims') {
|
||||
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
|
||||
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
|
||||
} else { out.kind = String(r.table); out.text = 'A row this page does not know how to show'; }
|
||||
out.group = out.groupAs || group(out.kind);
|
||||
return out;
|
||||
}
|
||||
function lines(tr, current = null) {
|
||||
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body?.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
|
||||
return tr.map(r => line(r, ctx));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render helpers
|
||||
function ago(iso) {
|
||||
const t = Date.parse(iso);
|
||||
if (!Number.isFinite(t)) return String(iso ?? 'unknown');
|
||||
const s = Math.max(0, Math.round((Date.now() - t) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
|
||||
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
|
||||
}
|
||||
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
|
||||
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
|
||||
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||||
let readAt = null;
|
||||
const readNote = () => `Read from the bus ${when(readAt)}.`;
|
||||
// Lead decision 63: what no Q1 read returns carries this label where it would show.
|
||||
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
|
||||
// Which choice authorizes which action on which target, as the broker recorded it at
|
||||
// decision.raise. Never inferred from the recommendation or an option's position.
|
||||
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
|
||||
const first = q => String(q ?? '').split('\n')[0];
|
||||
const due = d => d ? `<time datetime="${esc(d)}" title="${esc(d)}">${esc(String(d).slice(0, 10))}</time>` : '<span class="muted">none</span>';
|
||||
const user = u => `user ${esc(u)}`;
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null;
|
||||
function sections(route) {
|
||||
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra]) => {
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||||
}).join('');
|
||||
}
|
||||
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
|
||||
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
async function inboxView(get) {
|
||||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||||
countInbox(rows);
|
||||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you.</p>';
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
}
|
||||
async function decisionView(get, id) {
|
||||
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
|
||||
if (!d) return notFound(`No decision with id ${txt(id)}.`);
|
||||
const c = d.closed;
|
||||
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
|
||||
// A command is offered only for an open decision routed to the human, and only when
|
||||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||||
const answerable = !c && d.routeTo === 'human';
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
? `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${txt(o.key)}">Copy</button></div>`
|
||||
: '<p class="small muted">This option has no command here: its key is not a plain identifier.</p>';
|
||||
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${copy}</li>`;
|
||||
}).join('')}</ol>`;
|
||||
const earlier = await chain(get, d);
|
||||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<dl class="kv"><dt>Class</dt><dd>${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status" role="status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
}
|
||||
|
||||
// Tasks
|
||||
function markers(t) {
|
||||
const out = [];
|
||||
// The list has no trail, so it can only say the current state came from a Vikunja read;
|
||||
// the task page tells a change to a task the stack wrote from one it never wrote.
|
||||
if (t.external) out.push(t.external.kind === null ? badge(`last read from Vikunja (${t.external.via})`, 'attn') : t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.changed.length ? `: ${t.external.changed.join(', ')}` : ''}`, 'attn'));
|
||||
if (t.conflicts.length) out.push(badge(plural(t.conflicts.length, 'refused write'), 'danger'));
|
||||
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
|
||||
return out.join(' ');
|
||||
}
|
||||
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
|
||||
function taskRow(t) {
|
||||
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
|
||||
}
|
||||
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
|
||||
function freshness(rows) {
|
||||
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
|
||||
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
|
||||
}
|
||||
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
|
||||
async function tasksView(get) {
|
||||
const rows = await get('tasks'), ts = rows.map(r => task(r));
|
||||
if (!ts.length) return `${head('Tasks', freshness(rows))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
|
||||
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
|
||||
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
|
||||
const groups = buckets.map(b => {
|
||||
const g = open.filter(t => t.bucket === b).sort(byChanged), id = `b-bucket-${esc(b)}`;
|
||||
return `<section aria-labelledby="${id}"><h2 id="${id}">Bucket ${esc(b)} <span class="count">${g.length}</span></h2>${taskTable(g, `Bucket ${b} tasks`)}</section>`;
|
||||
}).join('');
|
||||
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
|
||||
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
|
||||
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || '<p class="state">No open tasks.</p>'}${doneHtml}${missingHtml}`;
|
||||
}
|
||||
async function taskView(get, ref) {
|
||||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||||
const tr = await get('trail', ref), t = task(row, tr);
|
||||
const ext = t.external ? `<dt>Vikunja</dt><dd>${t.external.kind === 'foreign' ? 'Not created by the stack; first read' : 'Changed in Vikunja after the stack wrote it'}${t.external.changed.length ? `: ${txt(t.external.changed.join(', '))}` : ''}${t.external.comments ? `, ${esc(plural(t.external.comments, 'new comment'))}` : ''} <span class="source">${txt(t.external.via)} read sent ${clock(t.external.readAt)}</span></dd>` : '';
|
||||
const conflicts = t.conflicts.length ? `<dt>Refused writes</dt><dd>${t.conflicts.map(c => `${txt(c.verb)}: Vikunja changed after the stack read it (expected ${txt(short(c.expected))}, found ${txt(short(c.actual))}) ${when(c.at)}`).join('<br>')}</dd>` : '';
|
||||
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
|
||||
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
|
||||
return `${head(title(t), `${sub} ${markers(t)}`)}
|
||||
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
|
||||
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
|
||||
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
|
||||
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
|
||||
}
|
||||
|
||||
// Agents
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||||
return `${head('Agents', readNote())}
|
||||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear.</p>
|
||||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="state">No role is held right now.</p>'}</section>
|
||||
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
|
||||
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
|
||||
}
|
||||
|
||||
// Trail
|
||||
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
||||
function trailList(rows, filter) {
|
||||
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
|
||||
if (!shown.length) return '<p class="muted">Nothing of this kind in the trail.</p>';
|
||||
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
|
||||
}
|
||||
async function trailView(get, kind, ref, q) {
|
||||
let rows;
|
||||
if (kind === 'task') {
|
||||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||||
rows = lines(await get('trail', ref), row.seq);
|
||||
} else {
|
||||
const d = await decision(get, ref);
|
||||
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
|
||||
// A decision's trail with its earlier versions, merged in the broker's order.
|
||||
const all = new Map();
|
||||
for (const x of [d, ...await chain(get, d)]) for (const r of x.trail) all.set(`${r.table}:${r.seq}`, r);
|
||||
rows = lines([...all.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.table).localeCompare(String(b.table)) || a.seq - b.seq));
|
||||
}
|
||||
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
|
||||
const base = `#/trail/${kind}/${enc(ref)}`;
|
||||
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
|
||||
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
|
||||
return `${head(name, `${readNote()} Oldest first. ${back}`)}
|
||||
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
|
||||
${trailList(rows, filter)}`;
|
||||
}
|
||||
|
||||
// Not found and states
|
||||
function notFound(why) { return `${head('Not found')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
|
||||
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the bus.</p>`;
|
||||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||||
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
|
||||
function why(err) {
|
||||
const code = `<code>${txt(err.code)}</code>`;
|
||||
const say = {
|
||||
'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.',
|
||||
unauthenticated: 'The bus did not accept this Console as the human.',
|
||||
'unknown-business': 'The bus does not know this business.',
|
||||
'read-only': 'The bus refused the read.',
|
||||
'not-configured': 'This Console has no bus configured: the system config could not be read when it started. The control board still works.',
|
||||
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>. Start one with <code>mosaic bus start <business></code>.',
|
||||
'outcome-unknown': 'The bus did not answer in time.',
|
||||
'invalid-request': 'The bus refused this address as a request.',
|
||||
unreachable: 'The Console server did not answer.',
|
||||
}[err.code] || 'The bus answered with something Console cannot use.';
|
||||
return `${say} (${code})`;
|
||||
}
|
||||
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
|
||||
const failedView = err => `${head(failTitle(err))}<div class="state state-error" role="alert"><p><b>${why(err)}</b> Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>`;
|
||||
const staleBanner = err => `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
|
||||
// ---------------------------------------------------------------- router
|
||||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||||
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
|
||||
function view(get, path, q) {
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||||
if (a === 'agents' && n === 1) return agentsView(get);
|
||||
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
|
||||
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
|
||||
return notFound();
|
||||
}
|
||||
let gen = 0, shown = null, timer;
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
|
||||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||||
function keep() {
|
||||
const a = document.activeElement, view = $('bus-view');
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'),
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
function restore(k) {
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.retry && e.hasAttribute('data-retry'));
|
||||
if (el && (k.href || k.copy || k.retry)) el.focus({ preventScroll: true });
|
||||
}
|
||||
async function render(navigated) {
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||||
const g = ++gen;
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
if (board) {
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
|
||||
if (g === gen) schedule();
|
||||
return;
|
||||
}
|
||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
|
||||
let html;
|
||||
const live = reader(true);
|
||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
catch (err) {
|
||||
const kept = reader(false);
|
||||
try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
|
||||
catch { html = failedView(err); }
|
||||
}
|
||||
if (g !== gen) return;
|
||||
const k = !navigated && shown === route ? keep() : null;
|
||||
$('bus-view').innerHTML = html; shown = route;
|
||||
sections(path);
|
||||
const h1 = $('bus-view').querySelector('h1');
|
||||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||||
if (k) restore(k);
|
||||
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||||
schedule();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
|
||||
// unavailable, the command text is selected so the person can copy it themselves.
|
||||
document.addEventListener('click', async e => {
|
||||
// The skip link moves focus only. Its #main would otherwise reach the hash router.
|
||||
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
|
||||
const b = e.target.closest('#bus-view [data-copy]');
|
||||
if (b) {
|
||||
const text = b.dataset.copy, out = $('copy-status');
|
||||
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
|
||||
catch {
|
||||
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
|
||||
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#bus-view [data-retry]')) render(false);
|
||||
});
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
|
||||
})();
|
||||
@@ -1,15 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><i>M</i>Mosaic<small>Console</small></a>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option>light</option><option>dim</option><option>dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<div id="error" class="state state-error" role="alert" hidden></div>
|
||||
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
|
||||
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
|
||||
@@ -17,6 +17,7 @@
|
||||
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
|
||||
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
|
||||
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
|
||||
<div id="bus-view" hidden></div>
|
||||
</main>
|
||||
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -19,8 +20,8 @@ export function boardURL(value) {
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
...['app.js', 'brand.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'console.css', 'live.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
function json(res, status, body) {
|
||||
@@ -41,7 +42,25 @@ async function body(req) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
|
||||
return bytes;
|
||||
}
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000 } = {}) {
|
||||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let subject;
|
||||
if (verb === 'trail') {
|
||||
subject = new URLSearchParams(search).get('subject');
|
||||
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||||
}
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const rows = await bus[verb](subject);
|
||||
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -79,6 +98,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/api/bus/')) {
|
||||
const verb = path.slice('/api/bus/'.length);
|
||||
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return busRead(res, bus, verb, search);
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
|
||||
@@ -18,7 +18,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 2);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 3);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// Project filtering does not hide another project's waiting requests.
|
||||
await b.evaluate('document.querySelector("[data-project=proj]").click()');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Slice 1 S5 (#1522): the inbox, tasks, agents and trail pages in a browser,
|
||||
// against an in-process broker read through its reader session, so every row
|
||||
// the page renders has the broker's own shape. Nothing here starts the human
|
||||
// CLI or reaches a real broker; the seeding writes go straight to the broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const digest = c => c.repeat(64);
|
||||
const iso = () => new Date().toISOString();
|
||||
// A poll read counts only when it was sent after the stack's own write (task_current), and the
|
||||
// broker's clock can run a few milliseconds ahead of Date.now() when writes come fast.
|
||||
const later = () => new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
function seed(root) {
|
||||
const store = new Store(root), b = new Broker({ store, businesses });
|
||||
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
|
||||
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
|
||||
launch('cto', 'claude-code', 'cto-thread');
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
|
||||
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
|
||||
const fields = { project_id: 32, title: `Inbox ${HOSTILE}`, description: `Line one ${HOSTILE}\nline two`, done: false, due_date: '2026-10-20T00:00:00Z', priority: 3, percent_done: 40, bucket: 2, labels: [], assignees: [5] };
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('a'), fields }], events: [{ kind: 'task.created', subject: 'vikunja:32/7', body: { request: asked.request, requirement: 'REQ-SLICE-1' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('b'), fields: { ...fields, bucket: 3 }, via: 'board', read_at: later() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { via: 'board', digest: digest('b'), previous: digest('a'), changed: ['bucket'], comments: 2 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
|
||||
// A task the stack never wrote, a done task and one no longer readable.
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
|
||||
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
|
||||
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
|
||||
const raise = args => b.request(coder, { verb: 'decision.raise', args });
|
||||
const push = { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', options: [{ key: 'yes', text: `Allow ${HOSTILE}` }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true };
|
||||
const old = raise({ ...push, question: 'Push the first candidate?' });
|
||||
const open = raise({ ...push, question: `Push the release? ${HOSTILE}\nSecond line`, supersedes: old.id });
|
||||
b.request(human, { verb: 'decision.seen', args: { id: open.id } });
|
||||
const routed = raise({ action: 'task.scope.change', question: 'Widen the scope?', options: [{ key: 'widen', text: 'Widen' }, { key: 'keep', text: 'Keep' }], recommendation: 'keep', blocking: false, approvalChoice: 'widen' });
|
||||
const resolved = raise({ ...push, question: 'Push the hotfix?', blocking: false, task_ref: undefined, target: 'hotfix' });
|
||||
b.request(human, { verb: 'decision.resolve', args: { id: resolved.id, choice: 'yes' } });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
let fail = null;
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
||||
}
|
||||
|
||||
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
|
||||
const s = seed(root), f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
const inertCheck = async where => {
|
||||
assert.equal(await b.evaluate('!!window.injected'), false, where);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view script, #bus-view img, #bus-view iframe, #bus-view object, #bus-view embed, #bus-view svg, #bus-view style, #bus-view link").length'), 0, where);
|
||||
const shown = await text('#bus-view');
|
||||
assert.doesNotMatch(shown, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/, where);
|
||||
};
|
||||
const noOverflow = async where => {
|
||||
for (const width of [320, 1440]) {
|
||||
await b.viewport(width, 1000);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${where} ${width} overflow`);
|
||||
if (out) await b.screenshot(join(out, `s5-${where}-${width}.png`));
|
||||
}
|
||||
await b.viewport(1440, 1000);
|
||||
};
|
||||
try {
|
||||
// Every request the page makes is logged with its method; the bus pages must make only GETs.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(1440, 1000);
|
||||
|
||||
// The board is untouched at the root, with the Console sections above its projects.
|
||||
await b.navigate(base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
||||
await wait('document.querySelector("#sections a[href=\\"#/inbox\\"] .count")?.textContent==="1"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
||||
|
||||
// Inbox: the one open human decision, its approval context, and the gap labels.
|
||||
await go('#/inbox', 'Inbox');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("on-bus")'), true);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
const card = await text('#bus-view .s1-dec');
|
||||
assert.match(card, /Push the release\? <script>window\.injected=1<\/script>/);
|
||||
assert.match(card, /␛\[31mred \[U\+202E\]evil/);
|
||||
assert.doesNotMatch(card, /Second line/);
|
||||
assert.match(card, /Choosing “yes” approves git\.push\.protected on refactor\./);
|
||||
assert.match(card, /blocking/);
|
||||
for (const label of ['Decisions routed to roles: not in the Q1 module', 'Closed decisions: not in the Q1 module', 'Seen and DM state per row: not in the Q1 module'])
|
||||
assert.ok((await text('#bus-view')).includes(label), label);
|
||||
assert.equal(await b.evaluate('document.title'), 'Inbox · Mosaic Console');
|
||||
await inertCheck('inbox'); await noOverflow('inbox');
|
||||
|
||||
// The open decision: options with Copy, history, the earlier version, no write.
|
||||
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()');
|
||||
await wait(`location.hash===${JSON.stringify(`#/inbox/${s.ids.open}`)}`);
|
||||
await wait('document.querySelectorAll("#bus-view [data-copy]").length===2');
|
||||
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view [data-copy]")].map(e=>e.dataset.copy)'), [`mosaic decide ${s.ids.open} yes`, `mosaic decide ${s.ids.open} no`]);
|
||||
const detail = await text('#bus-view');
|
||||
assert.match(detail, /approves git\.push\.protected/); assert.match(detail, /recommended/);
|
||||
assert.match(detail, /Seen by jason via cli/);
|
||||
assert.match(detail, /Second line/);
|
||||
assert.ok(detail.includes(`${s.ids.old.slice(0, 8)} withdrawn Push the first candidate?`), detail);
|
||||
assert.ok(detail.includes('A later version that replaces this one: not in the Q1 module'));
|
||||
assert.match(detail, /DMnot sent/);
|
||||
await inertCheck('decision');
|
||||
// Copy writes the command to the clipboard and nothing else; with no clipboard the command is selected.
|
||||
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("Copied")');
|
||||
assert.deepEqual(await b.evaluate('window.copied'), [`mosaic decide ${s.ids.open} yes`]);
|
||||
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].click()');
|
||||
await wait('document.querySelector("#copy-status").textContent.startsWith("The clipboard is unavailable")');
|
||||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${s.ids.open} no`);
|
||||
// A refresh keeps the focused button and the copy status.
|
||||
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].focus()');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.copy'), `mosaic decide ${s.ids.open} no`);
|
||||
assert.match(await text('#copy-status'), /clipboard is unavailable/);
|
||||
await noOverflow('decision');
|
||||
|
||||
// A decision routed to a role and a resolved one show no command.
|
||||
await go(`#/inbox/${s.ids.routed}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-view'), /routed to pm, not to you/);
|
||||
assert.match(await text('#bus-view'), /Choosing “widen” approves task\.scope\.change\./);
|
||||
await go(`#/inbox/${s.ids.resolved}`, 'Decision');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
assert.match(await text('#bus-status'), /Resolved: jason chose “yes” via cli/);
|
||||
assert.match(await text('#bus-view'), /chosen/);
|
||||
|
||||
// Tasks: buckets, markers, done, missing, the freshness line.
|
||||
await go('#/tasks', 'Tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/tasks');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view h2[id^=b-bucket]")].map(h=>h.textContent)'), ['Bucket 2 1', 'Bucket 3 1']);
|
||||
const tasks = await text('#bus-view');
|
||||
assert.match(tasks, /Inbox <script>window\.injected=1<\/script>/);
|
||||
// The list reads no trail: it says only that the current state came from a Vikunja read.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
|
||||
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
|
||||
assert.match(tasks, /#10 \(no title in the current read\)/);
|
||||
assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
|
||||
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
|
||||
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
|
||||
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
|
||||
await inertCheck('tasks'); await noOverflow('tasks');
|
||||
|
||||
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
|
||||
await go('#/tasks/vikunja%3A32%2F7', '#7');
|
||||
const t7 = await text('#bus-view');
|
||||
assert.match(t7, /RequirementREQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
|
||||
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
|
||||
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
|
||||
assert.match(t7, /Request from the human, recorded from the CLI/);
|
||||
assert.match(t7, /the request task\.created names/);
|
||||
assert.match(t7, /The human to pm: Build the inbox/);
|
||||
assert.match(t7, /Created for REQ-SLICE-1/);
|
||||
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
|
||||
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
|
||||
assert.match(t7, /Snapshots 2/);
|
||||
await inertCheck('task'); await noOverflow('task');
|
||||
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
||||
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
||||
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
|
||||
await go('#/tasks/vikunja%3A32%2F10', '#10');
|
||||
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
|
||||
|
||||
// The trail with filters.
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelector(".chips [aria-current=true]").textContent.startsWith("Task changes")'), true);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .s1-trow")].every(r=>r.classList.contains("s1-g-task"))'), true);
|
||||
await go('#/trail/task/vikunja%3A32%2F7?kind=request', 'Trail for task #7');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-trow").length'), 3); // the message, its delivery, the human.input
|
||||
await go(`#/trail/decision/${s.ids.open}`, 'Trail for decision');
|
||||
const dt = await text('#bus-view');
|
||||
assert.match(dt, /Raised with this context: choosing “yes” approves git\.push\.protected on refactor/);
|
||||
assert.match(dt, /Withdrawn by coder/); assert.match(dt, /jason saw it via cli/);
|
||||
assert.match(dt, /Push the first candidate\?/);
|
||||
await inertCheck('trail'); await noOverflow('trail');
|
||||
|
||||
// Agents: one row per held role, with its harness and address; the gaps are labelled.
|
||||
await go('#/agents', 'Agents');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
|
||||
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
|
||||
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
|
||||
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
|
||||
await noOverflow('agents');
|
||||
|
||||
// Addresses that are not pages.
|
||||
await go('#/nope', 'Not found');
|
||||
await go('#/inbox/bad%20id', 'Not found');
|
||||
assert.match(await text('#bus-view'), /bad id is not a decision id/);
|
||||
await go('#/tasks/vikunja%3A32%2F99', 'Not found');
|
||||
|
||||
// A failed read shows what was read before, labelled; a page never read shows the refusal.
|
||||
await go('#/inbox', 'Inbox');
|
||||
s.fail('outcome-unknown');
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
||||
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The bus did not answer in time\. \(outcome-unknown\)/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
||||
await go('#/trail/task/vikunja%3A32%2F9', 'The read failed');
|
||||
s.fail('human-required');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent==="Bus refused the read"');
|
||||
assert.match(await text('#bus-view'), /started it from their own shell.*\(human-required\).*Nothing was written\./s);
|
||||
s.fail(null);
|
||||
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
||||
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
|
||||
await noOverflow('refused');
|
||||
|
||||
// Back to the board: it still works.
|
||||
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
|
||||
await wait('document.querySelector("#bus-view").hidden && getComputedStyle(document.querySelector("#board-view")).display!=="none"');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 3);
|
||||
assert.equal(await b.evaluate('document.title'), 'Mosaic Console');
|
||||
|
||||
// The skip link focuses main and does not route.
|
||||
await b.evaluate('location.hash="#/agents"'); await wait('document.querySelector("#bus-view h1")?.textContent==="Agents"');
|
||||
await b.evaluate('document.querySelector(".skip").click()');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/agents');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main');
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
const requests = await b.evaluate('window.requests');
|
||||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
|
||||
// A Console with no bus configured says so, and its board still works.
|
||||
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
||||
assert.match(await text('#bus-view'), /no bus configured.*The control board still works\. \(not-configured\)/s);
|
||||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); await f.close(); s.store.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// Slice 1 S5 (#1522): the /api/bus/* routes and the Console's bus transport.
|
||||
// The routes run against an in-process broker through its reader session;
|
||||
// the transport runs a fake human CLI. Nothing here starts the real human
|
||||
// CLI or reaches a real broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { hostFile, startTimeOf } from '../../cli/src/host.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError, busReader, humanCall } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.injected=true"> evil';
|
||||
const at = '2026-10-08T12:00:00.000Z';
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tmp = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
|
||||
// A broker with one open decision, one claimed role and one externally changed task.
|
||||
function seeded(t) {
|
||||
const store = new Store(tmp(t, 'webui-bus-'));
|
||||
t.after(() => store.close());
|
||||
const b = new Broker({ store, businesses });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
const decision = b.request(coder, { verb: 'decision.raise', args: { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', question: `Push the release? ${HOSTILE}`, options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true } });
|
||||
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: at, digest: 'b'.repeat(64), fields: { title: HOSTILE, bucket: 11, done: false }, via: 'board', read_at: at }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { changed: ['bucket'] } }] });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
return { decision, bus, calls };
|
||||
}
|
||||
|
||||
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
|
||||
const { decision, bus, calls } = seeded(t);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
|
||||
const inbox = await read('/api/bus/inbox');
|
||||
assert.equal(inbox.status, 200); assert.equal(inbox.cache, 'no-store');
|
||||
assert.match(inbox.body.at, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.equal(inbox.body.rows.length, 1);
|
||||
assert.equal(inbox.body.rows[0].id, decision.id);
|
||||
assert.deepEqual(inbox.body.rows[0].authorization, { action: 'git.push.protected', target: 'refactor', approvalChoice: 'yes' });
|
||||
assert.ok(inbox.body.rows[0].question.endsWith(HOSTILE));
|
||||
assert.equal(inbox.body.rows[0].blocking, true);
|
||||
assert.deepEqual(inbox.body.rows[0].options.map(o => o.key), ['yes', 'no']);
|
||||
|
||||
const tasks = await read('/api/bus/tasks');
|
||||
assert.equal(tasks.body.rows.length, 1);
|
||||
assert.equal(tasks.body.rows[0].source, 'poll');
|
||||
assert.equal(tasks.body.rows[0].fields.title, HOSTILE);
|
||||
|
||||
const agents = await read('/api/bus/agents');
|
||||
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
|
||||
|
||||
const id = inbox.body.rows[0].id;
|
||||
const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));
|
||||
assert.equal(trail.status, 200);
|
||||
assert.ok(trail.body.rows.some(r => r.table === 'decisions' && r.id === id));
|
||||
const taskTrail = await read('/api/bus/trail?subject=vikunja:32/7');
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'task_snapshots'));
|
||||
assert.ok(taskTrail.body.rows.some(r => r.table === 'events' && r.kind === 'task.changed.external'));
|
||||
|
||||
// Bad subjects never reach the bus.
|
||||
const before = calls.length;
|
||||
for (const q of ['', '?subject=', '?subject=' + 'a'.repeat(161), '?subject=-x', '?subject=a%20b', '?subject=a%00b'])
|
||||
assert.deepEqual([q, (await read('/api/bus/trail' + q)).body.error], [q, 'invalid-request']);
|
||||
assert.equal(calls.length, before);
|
||||
assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
});
|
||||
|
||||
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
||||
const calls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
|
||||
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path);
|
||||
}
|
||||
assert.equal((await fetch(base + '/api/bus/inbox', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
assert.deepEqual(calls, []);
|
||||
for (const path of ['/bus.js', '/bus.css']) assert.equal((await fetch(base + path)).status, 200, path);
|
||||
});
|
||||
|
||||
test('bus refusals map to statuses and never carry a path or a stack', async t => {
|
||||
let fail;
|
||||
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => Promise.all([close(web), close(none)]));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
|
||||
for (const [code, status] of [['human-required', 403], ['unauthenticated', 403], ['unknown-business', 403], ['read-only', 403], ['outcome-unknown', 503], ['no-bus-host', 503], ['invalid-response', 502], ['response-too-large', 502], ['something-new', 502]]) {
|
||||
fail = new BusReadError(code);
|
||||
const [s, body] = await inbox();
|
||||
assert.deepEqual([code, s, body.error], [code, status, code]);
|
||||
}
|
||||
fail = Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { stack: 'at secret (/x.mjs:1)' });
|
||||
const [s, body] = await inbox();
|
||||
assert.equal(s, 502); assert.equal(body.error, 'invalid-response');
|
||||
assert.doesNotMatch(JSON.stringify(body), /home|mosaic-dev|secret|\.mjs/);
|
||||
const shape = await fetch(base + '/api/bus/tasks');
|
||||
assert.deepEqual([shape.status, (await shape.json()).error], [502, 'invalid-response']);
|
||||
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
|
||||
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
|
||||
});
|
||||
|
||||
// A fake human CLI: it reads the request, then acts on args.mode.
|
||||
const FAKE = `
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
const mode = request.args.mode;
|
||||
if (mode === 'refuse') { process.stderr.write('Some warning: Text\\nhuman-required\\n'); process.exit(2); }
|
||||
if (mode === 'garbage') { process.stdout.write('not json'); process.exit(0); }
|
||||
if (mode === 'silent-fail') process.exit(2);
|
||||
if (mode === 'big') { process.stdout.write('"' + 'x'.repeat(4096) + '"'); process.exit(0); }
|
||||
if (mode === 'slow') { setTimeout(() => process.stdout.write('[]'), 60000); }
|
||||
else if (mode === 'pid') { writeFileSync(request.args.pidFile, String(process.pid)); setInterval(() => {}, 60000); }
|
||||
else process.stdout.write(JSON.stringify({ request, socket: process.argv[2] }));
|
||||
`;
|
||||
function fake(t) {
|
||||
const root = tmp(t, 'webui-bus-cli-');
|
||||
const cli = join(root, 'fake-human-cli.mjs');
|
||||
writeFileSync(cli, FAKE);
|
||||
return { root, cli };
|
||||
}
|
||||
|
||||
test('humanCall speaks the human transport protocol without blocking the server', async t => {
|
||||
const { cli } = fake(t);
|
||||
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, timeoutMs: 1500, maxBytes: 1024 });
|
||||
assert.deepEqual(await call('trail', { subject: 'd-1' }), { request: { business: 'demo', verb: 'trail', args: { subject: 'd-1' } }, socket: '/run/fake.sock' });
|
||||
const code = async args => { try { await call('inbox', args); return 'resolved'; } catch (e) { assert.ok(e instanceof BusReadError); return e.code; } };
|
||||
assert.equal(await code({ mode: 'refuse' }), 'human-required');
|
||||
assert.equal(await code({ mode: 'garbage' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'silent-fail' }), 'invalid-response');
|
||||
assert.equal(await code({ mode: 'big' }), 'response-too-large');
|
||||
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
|
||||
|
||||
// A slow read stays slow on its own: the server keeps answering meanwhile.
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const started = Date.now();
|
||||
const slow = fetch(base + '/api/bus/inbox');
|
||||
const health = await fetch(base + '/healthz');
|
||||
assert.equal(health.status, 200);
|
||||
assert.ok(Date.now() - started < 1000, 'healthz waited for the bus read');
|
||||
const r = await slow;
|
||||
assert.deepEqual([r.status, (await r.json()).error], [503, 'outcome-unknown']);
|
||||
assert.ok(Date.now() - started >= 1400);
|
||||
});
|
||||
|
||||
test('busReader takes --business, else the live bus host, else refuses', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const dataRoot = join(root, 'data');
|
||||
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
|
||||
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
||||
|
||||
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
||||
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
mkdirSync(join(dataRoot, 'bus-host'), { recursive: true });
|
||||
writeFileSync(hostFile(dataRoot), '{not json');
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: 'not-my-start', business: 'stale' }));
|
||||
assert.equal(await code(), 'no-bus-host');
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'hosted' }));
|
||||
const reply = await reader.agents();
|
||||
assert.deepEqual([reply.request.business, reply.request.verb, reply.socket], ['hosted', 'agents', 'sock']);
|
||||
// The host is read on every request.
|
||||
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'moved' }));
|
||||
assert.equal((await reader.tasks()).request.business, 'moved');
|
||||
});
|
||||
|
||||
test('humanCall kills a transport that runs past its timeout', async t => {
|
||||
const { root, cli } = fake(t);
|
||||
const pidFile = join(root, 'pid');
|
||||
const call = humanCall({ socket: 's', business: 'demo', cli, timeoutMs: 500 });
|
||||
assert.equal(await call('inbox', { mode: 'pid', pidFile }).catch(e => e.code), 'outcome-unknown');
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'));
|
||||
const gone = () => { try { process.kill(pid, 0); return false; } catch { return true; } };
|
||||
// If it survived, stop it so the failure is this assertion, not a hang.
|
||||
t.after(() => { if (!gone()) process.kill(pid, 'SIGKILL'); });
|
||||
// SIGKILL lands asynchronously; give the kernel a moment to reap it.
|
||||
for (let i = 0; i < 40 && !gone(); i++) await new Promise(r => setTimeout(r, 25));
|
||||
assert.ok(gone(), `transport ${pid} still runs after the timeout`);
|
||||
});
|
||||
|
||||
test('serve refuses a --business value that is not a business id', () => {
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
for (const business of ['../acme', '-x', 'acme corp']) {
|
||||
const result = spawnSync(process.execPath, [cli, 'serve', '--business', business, '--port', '0'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(result.status, 2, business);
|
||||
assert.match(result.stderr, /^refused: business must be a business id/, business);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user