dewey: row 76 round 2 packet, rebase on 729da72b and decisions 99-100 (#1570)

Rebased on row 59 (a8512007). Darkwing's N1-N5 and the Q1 proxy line
(lead decision 99), Filbert's N1-N4 (lead decision 100): a 408 is unknown
on the send box and the board Reply, the receipt shows the code once, focus
and caret kept, a role no longer held kept, Inbox current on a message
trail, README notes. 35 of 35 mutants killed; gate green on 729da72b.
Manifest sha256 04ecf1b57c594a35d34d53277b2df915a571f4fa9f7cc29146dbedfa612f77d4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 21:38:09 -05:00
co-authored by Claude Opus 5.5
parent 534fe53e3a
commit 86821889ed
6 changed files with 386 additions and 137 deletions
+234 -61
View File
@@ -1,13 +1,23 @@
# Row 76 (#1570): Console sends a human message to a role, round 1
# Row 76 (#1570): Console sends a human message to a role, round 2
Owner: Dewey. Reviewers: Darkwing and Filbert. Brief:
`docs/plans/2026-10-11_dogfood-cutover.md` § "Console sends a message to a
role", blob `f5d56554` (rev 552). Jason's Q95 answer is lead decision 95;
the reply view question is lead decision 97, option (a). Base `3d298920`
(row 60 landed). None of the candidate's paths changed between `3d298920`
and `refactor` HEAD (`3d298920`) when the packet was built.
the reply view question is lead decision 97, option (a). Base `729da72b`
(row 59 landed in `a8512007`). None of the candidate's paths changed
between `729da72b` and `refactor` HEAD (`729da72b`) when the packet was
built.
`build.patch` is `git diff --binary 3d298920 -- packages/webui`, the two
Round 2 is the rebase on row 59, Darkwing's round 1 notes N1 to N5 with
a Q1 line in the README (lead decision 99), and Filbert's round 1 notes
(lead decision 100). They are listed separately below: "Round 2: rebase
on row 59" for the merged hunks, then "Round 2: Darkwing's notes (lead
decision 99)" and "Round 2: Filbert's notes (lead decision 100)" for the
content changes.
The rest of this file is round 1's, updated where those changes touch it,
with the mutants, gate and demonstration rerun on the round 2 tree.
`build.patch` is `git diff --binary 729da72b -- packages/webui`, the two
new test files included. Apply it with `git apply --index` to a detached
worktree of the base; `candidate-manifest.sha256` then checks 9/9 OK.
@@ -87,11 +97,11 @@ role": a picker of held roles from the agents read, a text box and Send.
message and its deliveries, back link to the Inbox, not-found for an id
the trail doesn't hold). The text is cleared unless it was edited during
the send.
- Not sent: a 4xx, or the server's `sent: false` (a 503 `no-bus-host`
too), or a redirect. Only the sign-in proxy redirects, when its session
- Not sent: a 4xx other than 408, or the server's `sent: false` (a 503
`no-bus-host` too), or a redirect. Only the sign-in proxy redirects, when its session
has expired, so the request never reached the Console; the receipt asks
for a reload. The draft is kept.
- Anything else, a lost answer included: outcome unknown. The receipt says
- Anything else, a lost answer and a 408 included: outcome unknown. The receipt says
the message may have been sent, that nothing was sent again, and that
there is no trail to link without an id; the draft is kept.
- The box says a reply comes back as a message the Console doesn't show
@@ -99,9 +109,9 @@ role": a picker of held roles from the agents read, a text box and Send.
(decision 97).
**Reply in the board (`public/app.js`).** A refused Reply said "Delivery
may be unknown; check the seat before sending again" for a 403. A 4xx now
says "Not sent."; no answer, an unreadable one or a 5xx keeps the old
text. Row 74 left this for this row.
may be unknown; check the seat before sending again" for a 403. A 4xx other
than 408 now says "Not sent."; no answer, an unreadable one, a 5xx or a
408 keeps the old text. Row 74 left this for this row.
**README.** A "Message a role" section: the command, what `--send` grants
(anyone the proxy lets through as ID, and any local process that reaches
@@ -135,7 +145,8 @@ pointer to `scripts/webui-service.sh`, which takes no `--send`.
codes, an uncoded error or a missing id as unknown. The page adds 4xx
and the redirect as definite. A proxy 4xx means the request wasn't
accepted for forwarding; a proxy 5xx (502, 504) can come after the
Console got it, so it stays unknown.
Console got it, so it stays unknown, and so does a 408 (round 2,
Filbert's N1).
6. **The draft is in page memory, not storage.** The brief asks for it to
survive the periodic refresh. Keeping it out of `localStorage` keeps a
message body off disk on a shared browser profile.
@@ -154,7 +165,141 @@ pointer to `scripts/webui-service.sh`, which takes no `--send`.
it has spaces (`error=cross-origin request refused`). It is always last
on the line.
## Rebase on row 60
## Round 2: rebase on row 59
Round 1's candidate was on `3d298920` (candidate manifest `ca870314`,
request comment 27594). Row 59 landed first in `a8512007`, so I applied
round 1's `build.patch` to `729da72b` with `git apply --3way --index`.
Every file applied cleanly, with no conflict.
Compared by added and removed lines, file by file, round 2's patch is
identical to round 1's in all nine files. Only the context around the
hunks moved. Two manifest digests change, `packages/webui/README.md` and
`packages/webui/src/public/bus.js`, because row 59 changed those files
too. The other seven digests are the same as round 1's.
Row 59's hunks in this row's files, and how each merged:
- `packages/webui/README.md`: row 59 adds the "Agent state (row 59,
#1548)" section, lines 270-280 in the candidate, between "Inbox, tasks,
agents and trails" and "Shell". My nearest hunks are in "Inbox, tasks,
agents and trails" above it (the route list gains
`#/trail/message/<id>`, and "Nothing writes" names the send box) and in
"Data and boundaries" below it (without `--send` no route writes).
Neither touches row 59's section. It merged cleanly.
- `packages/webui/src/public/bus.js`: row 59 adds `stateMark` and
`attention` and changes `agentsView`, lines 367-390 in the candidate
(State and Attention columns). My hunks sit above it (the header
comment, and the send form's `draft` and `composerHtml`, lines 246-287)
and below it (the message trail, the router, focus restore, and the
submit handler). Neither touches the agents view. It merged cleanly.
`stateMark`, `attention`, `plural`, `blockChip`, `first` and `short`
are each defined once.
Row 59's other files (`packages/bus/README.md`, `broker.mjs`,
`agents.test.mjs`, the webui `bus.css`, `bus-browser.test.mjs` and
`bus.test.mjs`) aren't files this row touches. The mutants, the suites and the
demonstration below ran on the round 2 tree.
## Round 2: Darkwing's notes (lead decision 99)
Content changes made after the rebase, against Darkwing's round 1 review
(comment 27612, `agents/darkwing/work/queue-76-review/review-r1.md`). They
change three files: `packages/webui/README.md`, `src/public/bus.js` and
`tests/send-browser.test.mjs`. Each was a small change; none is left for a
follow-up row.
- **N1, the board Reply's "Not sent." (`app.js:378`) held by a browser
test.** `send-browser.test.mjs`'s stub now answers `/api/reply` from a
queue when one is set. The test opens an inspection, types a reply, and
answers a 403 and then a 502. The 403 must show `failed: refused here.
Not sent.` and the 502 `failed: board unreachable. Delivery may be
unknown; check the seat before sending again.`, the reply text kept both
times, and the board's capture list stays empty. `app.js` itself is
unchanged from round 1. The mutants `reply-4xx-unknown` and
`reply-5xx-refused` check that the test catches both directions.
- **N2, the caret across a refresh.** The refresh case now selects
characters 3 to 7 in the text box, presses Refresh, and asserts focus
and the selection `{start: 3, end: 7}`, after the pattern at
`browser.test.mjs:55`. Mutants `page-no-caret` (not restored) and
`page-caret-not-kept` (not saved).
- **N3, README "Message a role".** "It is recorded exactly like one sent
from the CLI" is now "It goes the same way as one sent from the CLI,
with class INFO where `mosaic talk` uses REQUEST". The class stays INFO.
The box's note in `bus.js` said the same thing ("recorded by the bus
exactly like one sent from the CLI"); it now says "the same way as one
sent from the CLI, with class INFO". No test pins that sentence.
- **N4, README reply path.** "talk prints each one and records it
delivered" is now: the broker records each one delivered when
`message.receive` returns it, so the next receive doesn't return it
again; talk prints each one and marks it read (`message.read`,
`cli.mjs:208`).
- **N5, the code shown twice in a refusal receipt.** The broker's
refusals arrive as `not sent: the bus refused it (unknown-role)`, and
the receipt added ` (unknown-role)` again, after its own "Not sent to
coder:". The receipt now drops the server's leading `not sent: ` and
adds the code only when the message doesn't already carry `(code)`. It
reads `Not sent to coder: the bus refused it (unknown-role). The draft is
kept.` A refusal whose message lacks the code (a 4xx from a check before
the bus, or a proxy's) still gets it. Dropping the prefix goes past N5's
letter; without it the receipt said "Not sent to coder: not sent: ...".
The browser test now asserts the two receipts as exact strings (the 400
`unknown-role` and the 503 `no-bus-host`). Mutants `page-code-twice` and
`page-keeps-not-sent-prefix`.
- **Q1, the proxy contract.** A new bullet in "What the proxy must do":
the proxy passes the Console's status and body through and doesn't
rewrite upstream errors (nginx's `proxy_intercept_errors` with an
`error_page`, for example), because the page reads a 4xx as "not sent"
and a rewritten 503 `outcome-unknown` would then report a message that
may exist as not sent.
- **Q2** stays as built (Darkwing: yes, refuse at start).
## Round 2: Filbert's notes (lead decision 100)
Filbert's round 1 review (comment 27637, approve). These change
`packages/webui/README.md`, `src/public/bus.js`, `src/public/app.js`,
`tests/send.test.mjs` and `tests/send-browser.test.mjs`. N1 is the one
behaviour change; N3 is tests only. Each was a small change; none is left
for a follow-up row. Filbert's N2 is Darkwing's N1 and their N4 is
Darkwing's N5, both above.
- **N1, a 408 is unknown, not "not sent".** A proxy can answer 408 after
it has forwarded the request (Envoy's `stream_idle_timeout`, when no
upstream headers arrived in time). The send box's check
(`bus.js`, the `refused` branch) and the board Reply's (`app.js:378`) now
both leave 408 out of the 4xx they read as not sent; a 408 shows the
unknown receipt. A 408 that carries the Console's own `sent: false` would
still read as not sent, but the Console never answers 408. Tests: the
send box's unknown loop gains a 408, and the board Reply case a third
answer, 408, which must say delivery may be unknown with the text kept.
README: the "Outcome unknown" bullet says a 408 counts as unknown, here
and on the Reply, and the Q1 proxy bullet says "a 4xx other than 408".
Mutants `page-408-refused` and `reply-408-refused`.
- **N3, Filbert's surviving mutants, each now held by a test:**
- `p-no-field-focus` and `p-no-caret`: the refresh case leaves focus in
the text box (the button is clicked from script, which doesn't move
focus), then asserts focus and the selection (Darkwing's N2 above).
Mine: `page-no-field-focus`, `page-no-caret`, `page-caret-not-kept`.
- `p-not-held-dropped`: with `pm` selected, the test ends `pm`'s run in
the broker (`endLaunch`, which releases its claim) and refreshes. The
picker must still hold `pm`, selected, as `pm (not held now)`. Mine:
`page-not-held-dropped`.
- `p-inbox-not-current`: on `#/trail/message/<id>` the Inbox is the
current section (`aria-current="page"`). Mine:
`page-inbox-not-current`.
- `s-reply-truthy`: the passthrough test sends `in_reply_to: ""` and
asserts the key reaches `send` with the empty value. Mine:
`send-reply-truthy`.
- `s-plain-error-code`: the plain `Error` in the outcomes table
(`send.test.mjs`, the `ENOENT` case) now carries `code: 'ENOENT'`, as a
socket error does, and must still be 503 `outcome-unknown`. Mine:
`outcome-plain-error-code`.
- `s-log-cut-65`: the forged-line test asserts the logged `to` is the
first 64 characters exactly, and a 65-character role logs without its
last one. Mine: `send-log-cut-65`.
- `s-array-ok` is equivalent, as Filbert says; no test.
## Rebase on row 60 (round 1)
I built this row on `bbd22f1b`. Row 60 landed first (`1771bf98`), so I
applied my patch to `3d298920` with `git apply --3way`. Row 60's hunks in
@@ -180,7 +325,8 @@ this row's files, and how each merged:
```
Row 60's `tests/bus.test.mjs` change isn't in a file this row touches. The
mutants, the suites and the demonstration below ran on the rebased tree.
mutants, the suites and the demonstration below ran on round 2's tree (the
rebase on `729da72b` with every round 2 change).
## Files
@@ -189,11 +335,11 @@ mutants, the suites and the demonstration below ran on the rebased tree.
| `packages/webui/src/serve.mjs` | `busSend`, the route under `send`, `body()` limit parameter, `send`/`log` options, `/api/config` `send` |
| `packages/webui/src/bus.mjs` | `busCaller` split out of `busReader`; `sendOutcome`; three codes added to the status map |
| `packages/webui/src/cli.mjs` | `--send`, refused without the preview flags or the bus; the `Preview:` line |
| `packages/webui/src/public/bus.js` | the "Message a role" box, the message trail kind, focus and caret kept across a refresh |
| `packages/webui/src/public/app.js` | the Reply 4xx text |
| `packages/webui/README.md` | the "Message a role" section and the folded-in row 74 notes |
| `packages/webui/tests/send.test.mjs` | new, 13 tests |
| `packages/webui/tests/send-browser.test.mjs` | new, 1 browser test |
| `packages/webui/src/public/bus.js` | the "Message a role" box, the message trail kind, focus and caret kept across a refresh; round 2: the refusal receipt shows the code once, the box's note, a 408 stays unknown |
| `packages/webui/src/public/app.js` | the Reply 4xx text; round 2: a 408 stays unknown |
| `packages/webui/README.md` | the "Message a role" section and the folded-in row 74 notes; round 2: Darkwing's N3 and N4, the Q1 proxy bullet, the 408 line |
| `packages/webui/tests/send.test.mjs` | new, 13 tests; round 2: `in_reply_to: ""`, a coded plain error, the 64-character log cut |
| `packages/webui/tests/send-browser.test.mjs` | new, 1 browser test; round 2: the board Reply 403, 502 and 408, focus and caret, a role no longer held, exact refusal receipts, a 408 send, Inbox current on a message trail |
| `packages/webui/tests/shell.test.mjs` | the pinned write boundary: bus.js has exactly one write, this one |
## Tests
@@ -215,9 +361,10 @@ or repeated `Sec-Fetch-Site`, no or wrong Content-Type, a form, 16385
bytes, extra keys, `business`, missing `to` or `body`, not JSON, null, an
array, a string) refused with `sent: false`, logged without the body, and
16384 bytes accepted; wrong Origins refused by the shared check; GET and
PUT 405; exactly `to`, `body` and `in_reply_to` passed on; a crafted `to`
can't forge a log line; each bus refusal with its status and `sent:
false`, each open outcome 503 `unknown`, a missing id unknown, and one
PUT 405; exactly `to`, `body` and `in_reply_to` passed on, an empty
`in_reply_to` included; a crafted `to` can't forge a log line, and `to` is
cut at 64 characters; each bus refusal with its status and `sent:
false`, each open outcome 503 `unknown` (a plain error with a code too), a missing id unknown, and one
call each (no retry); the human transport request is exactly the business,
`message.send` and the keys; against a real broker the message is written
once as a human send (`from_role` human, `from_run` human-cli, class INFO)
@@ -227,30 +374,37 @@ and `unknown-role`, `invalid-request` and `message-not-found` map to 400.
broker for the roles and the trail and a stub for the POST (a send passes
only with the proxy's https Origin, which a loopback page can't have): no
box without `send`; the held roles; empty role and text refused without a
request; the draft across a refresh; a 400 and a 503 `sent: false`
refused with the draft kept; a redirect refused as `sign-in`; an unknown
outcome, a network failure and a 200 without an id each unknown, with no
request; the draft, focus and caret across a refresh; a role no longer
held kept selected as "(not held now)"; a 400 and a 503
`sent: false` refused with the draft kept, each receipt asserted exactly
with its code once; a redirect refused as `sign-in`; an unknown
outcome, a network failure, a 200 without an id and a 408 each unknown, with no
trail link and no resend; one POST per Send, pinned as
`{method, headers, body, redirect: 'manual'}`; Send disabled while
pending and a second press ignored; text typed during a send kept; a plain
success clears the text and keeps the role; the receipt's trail link opens
the message's trail; an unknown message id is not found.
the message's trail, with the Inbox the current section; an unknown
message id is not found; the board's Reply answered 403 says "Not sent."
and 502 and 408 say delivery may be unknown, the reply text kept each time
and nothing captured.
## Mutants
`mutants.py` makes 21 single-site edits, one at a time, in a separate
worktree of the rebased candidate (never the build tree). Each mutant runs
against the whole webui suite, and the file is restored from the copy read
before the edit. The baseline passed, the worktree was unchanged afterwards,
and **21 of 21 were killed** (2026-10-12T00:40:39Z to 01:02:24Z).
`mutants.txt` lists each mutant with the tests that killed it.
`mutants.py` makes 35 single-site edits, one at a time, in a separate
worktree of round 2's tree (never the build tree). Each mutant runs against
the whole webui suite, and the file is restored from the copy read before
the edit. The baseline passed, the worktree was unchanged afterwards, and
**35 of 35 were killed** (2026-10-12T01:54:19Z to 02:29:20Z). `mutants.txt`
lists each mutant with the tests that killed it.
Round 1's 21:
- Route (`serve.mjs`): the route's own header check removed; Origin
unchecked; `Sec-Fetch-Site` unchecked, or `same-site` let through; the
limit raised to 64 KiB; extra keys allowed; `in_reply_to` dropped; the
id check removed; the log's `to` unquoted and uncut; `/api/config`
`send` true on loopback; the route open without `--send`; every write
opened by `--send` (`send-opens-writes`, killed by the new "every other
opened by `--send` (`send-opens-writes`, killed by the "every other
write is still 403" test).
- Outcomes (`bus.mjs`): only `outcome-unknown` left open; `unknown-role`
dropped from the status map.
@@ -259,9 +413,27 @@ and **21 of 21 were killed** (2026-10-12T00:40:39Z to 01:02:24Z).
resent; a redirect treated as unknown; redirects followed; the message
trail shown for any id.
I ran 20 of these on the `bbd22f1b` build before the rebase. The 18 that
finished there were killed too; the run was stopped for the rebase and its
log is not in the packet.
Round 2's 14, one or more for each reviewer note that changed behaviour or
asked for a test:
- Board Reply (`app.js`): a 4xx read as unknown (`reply-4xx-unknown`); a
5xx read as not sent (`reply-5xx-refused`); a 408 read as not sent
(`reply-408-refused`). Darkwing's N1, Filbert's N2 and N1.
- Send box (`bus.js`): the caret not restored (`page-no-caret`), restored
to the end (`page-caret-not-kept`), focus not restored
(`page-no-field-focus`); the code shown twice (`page-code-twice`); the
"not sent: " prefix kept (`page-keeps-not-sent-prefix`); a 408 read as
not sent (`page-408-refused`); a role no longer held dropped from the
picker (`page-not-held-dropped`); the Inbox not current on a message
trail (`page-inbox-not-current`). Darkwing's N2 and N5, Filbert's N1 and
N3.
- Route and outcomes: an empty `in_reply_to` dropped (`send-reply-truthy`);
a plain error with a `code` read as definite
(`outcome-plain-error-code`); the log's `to` cut at 65
(`send-log-cut-65`). Filbert's N3.
Round 1's run on the `bbd22f1b` build and its 21-of-21 run on the row 60
rebase are not repeated here; this run covers all 35 on the final tree.
## Demonstration
@@ -272,12 +444,12 @@ browser send (`Host: NAME`, `x-authentik-uid: jason`, Origin,
`Sec-Fetch-Site`). Three servers: `send`, `lost` (the broker writes, then
the call throws `outcome-unknown`, as when the transport loses the
answer) and `readonly` (no `--send`). `demo.txt` is its output on the gate
worktree:
worktree of round 2's tree:
```text
== a send, and a reply to the coder
to coder 200 {"id":"8f66630d-cb38-4879-8119-fb3defd532c8","at":"2026-10-12T00:40:48.392Z"}
to coder, in_reply_to the coder's message 200 {"id":"3a35d7b4-2020-464e-9bc3-5ee870c8b53f","at":"2026-10-12T00:40:49.041Z"}
to coder 200 {"id":"9db1d4eb-2abd-4c13-a388-2ed3d9503c07","at":"2026-10-12T02:37:11.862Z"}
to coder, in_reply_to the coder's message 200 {"id":"c4b38d2a-34df-4dea-b31b-897f7de01777","at":"2026-10-12T02:37:11.877Z"}
== refused before the bus
no sign-in header (401, no log line) 401 {"error":"sign-in required"}
loopback Host with the header 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
@@ -297,34 +469,35 @@ unknown, not retried 503 {"error":"outcome-unknown","
== without --send
POST is read-only 403 {"error":"read-only","message":"the Console preview is read-only"}
== what the broker holds
8f66630d from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
3a35d7b4 from=human/human-cli to=coder class=INFO in_reply_to=0536126f-c54a-40a5-999d-c7fe1c01d9a2 body="refactor"
9db1d4eb from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
c4b38d2a from=human/human-cli to=coder class=INFO in_reply_to=8c236a3d-c415-4252-818c-015cdd81d185 body="refactor"
pm receives 1 message(s) from the lost-answer send: ["Lost answer test"]
== the Console log, one line per send past sign-in
2026-10-12T00:40:48.392Z send user="jason" to="coder" id=8f66630d-cb38-4879-8119-fb3defd532c8
2026-10-12T00:40:49.041Z send user="jason" to="coder" id=3a35d7b4-2020-464e-9bc3-5ee870c8b53f
2026-10-12T00:40:49.057Z send user="jason" to=none error=cross-origin request refused
2026-10-12T00:40:49.058Z send user="jason" to=none error=cross-site request refused
2026-10-12T00:40:49.061Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.066Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.072Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.281Z send user="jason" to="auditor" error=unknown-role
2026-10-12T00:40:49.535Z send user="jason" to="coder" error=invalid-request
2026-10-12T00:40:49.802Z send user="jason" to="coder" error=message-not-found
2026-10-12T00:40:50.173Z send user="jason" to="pm" error=outcome-unknown
2026-10-12T02:37:11.862Z send user="jason" to="coder" id=9db1d4eb-2abd-4c13-a388-2ed3d9503c07
2026-10-12T02:37:11.877Z send user="jason" to="coder" id=c4b38d2a-34df-4dea-b31b-897f7de01777
2026-10-12T02:37:11.881Z send user="jason" to=none error=cross-origin request refused
2026-10-12T02:37:11.882Z send user="jason" to=none error=cross-site request refused
2026-10-12T02:37:11.883Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.885Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.886Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.892Z send user="jason" to="auditor" error=unknown-role
2026-10-12T02:37:11.899Z send user="jason" to="coder" error=invalid-request
2026-10-12T02:37:11.905Z send user="jason" to="coder" error=message-not-found
2026-10-12T02:37:11.913Z send user="jason" to="pm" error=outcome-unknown
a body in the log: false
```
## Gate (my run on the candidate)
Detached worktree of `3d298920`, `build.patch` applied with `git apply
--index`, the manifest 9/9 OK before and after. Suites one after another,
`TMPDIR` in my scratch, Docker on, 2026-10-12T01:02:24Z to 01:09:40Z:
Detached worktree of `729da72b`, `build.patch` applied with `git apply
--index`, the manifest (sha256 `04ecf1b5…`) 9/9 OK before and after.
Suites one after another, `TMPDIR` in my scratch, Docker on,
2026-10-12T02:29:20Z to 02:37:11Z:
| Suite | Result |
|---|---|
| `packages/webui` | 76 pass, 0 fail, 0 skipped, the browser tests included (14 new) |
| `packages/bus` | 88 pass, 0 fail |
| `packages/bus` | 94 pass, 0 fail (88 in round 1; row 59 added 6) |
| `packages/cli` | 114 pass, 0 fail |
| `packages/control-board` | 124 pass, 0 fail |
| business, conversation, discord, harness, ledger, mosaic, queue, runs, seat, tasks | all 0 fail (60, 182, 178, 59, 78, 69, 150, 41, 27, 71 pass) |
@@ -339,8 +512,8 @@ Detached worktree of `3d298920`, `build.patch` applied with `git apply
## Questions for reviewers
1. Decision 5: is a proxy 4xx safe to show as "not sent"? I think so for
the proxies we'd use, but a reviewer who knows one that answers 4xx
after forwarding should say.
2. Decision 7: refusing `--send` at start without the bus config, rather
than serving reads and answering `not-configured` on each send.
Round 1's two questions are answered. Q1 (decision 5, a proxy 4xx shown
as "not sent"): Darkwing said yes, with a proxy-contract line, now added;
Filbert said yes for nginx, Caddy and Traefik except 408, now unknown.
Q2 (decision 7, refusing `--send` at start without the bus config): both
said yes; kept as built. Round 2 has no new question.
+94 -48
View File
@@ -1,5 +1,5 @@
diff --git a/packages/webui/README.md b/packages/webui/README.md
index e2ffcb3b..6242cd34 100644
index c866804c..5ac12d95 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -23,7 +23,7 @@ Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
@@ -30,7 +30,7 @@ index e2ffcb3b..6242cd34 100644
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -80,7 +82,8 @@ controls it:
@@ -80,14 +82,117 @@ controls it:
operator's path and skips the header check, so a proxy that forwarded a
client's `Host: 127.0.0.1:7332` would let anyone through without signing
in. A tunnel whose local end uses the same port number sends such a Host
@@ -39,8 +39,13 @@ index e2ffcb3b..6242cd34 100644
+ end can send a loopback Host without HEADER, so bind that end to loopback.
- Every hop on this host that reaches the loopback port is bound by this
contract too, not only the edge.
+- It passes the Console's status and body through and doesn't rewrite
+ upstream errors (nginx's `proxy_intercept_errors` with an `error_page`,
+ for example). Under `--send` the page reads a 4xx other than 408 as "not
+ sent", so a proxy that turned the Console's 503 `outcome-unknown` into a
+ 4xx or a redirect would report a message that may exist as not sent.
@@ -88,6 +91,99 @@ The local exposure doesn't change ("Data and boundaries"): any process on
The local exposure doesn't change ("Data and boundaries"): any process on
the host can still connect to the port and send `Host: NAME` with HEADER and
ID, or just a loopback Host, and read what a reader capability reads. The
user id is not a secret; the sign-in at the edge is what keeps others out.
@@ -64,8 +69,9 @@ index e2ffcb3b..6242cd34 100644
+Console would still serve the board. With `--send` the Inbox has a "Message
+a role" box: a role picker filled from the agents read, a text box and
+Send. The message reaches the role as a message from the human, class INFO,
+through `message.send` on the human transport. It is recorded exactly like
+one sent from the CLI (lead decision 95). Decisions are still answered in a
+through `message.send` on the human transport. It goes the same way as one
+sent from the CLI, with class INFO where `mosaic talk` uses REQUEST (lead
+decision 95). Decisions are still answered in a
+terminal. There are no other message classes, no corrections and no seat
+conversations here.
+
@@ -126,7 +132,9 @@ index e2ffcb3b..6242cd34 100644
+ sign in again.
+- Outcome unknown says the message may have been sent, keeps the draft, and
+ has no trail to link, since no id came back. Check the role's side, or
+ the bus trail from a terminal, before sending again.
+ the bus trail from a terminal, before sending again. A 408 counts as
+ unknown, here and on the board's Reply, since a proxy can answer it
+ after forwarding the request (Envoy's `stream_idle_timeout`).
+
+The role's reply comes back as a message to the human. The Console doesn't
+show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a
@@ -135,12 +143,13 @@ index e2ffcb3b..6242cd34 100644
+- talk receives only after it sends its own message;
+- `--wait 0` returns straight after the send and receives nothing;
+- otherwise each receive returns every undelivered message to the human, up
+ to 100 a call. talk prints each one and records it delivered, so the next
+ receive doesn't return it again.
+ to 100 a call. The broker records each one delivered when
+ `message.receive` returns it, so the next receive doesn't return it
+ again; talk prints each one and marks it read.
## Use
@@ -133,14 +229,14 @@ delivery may be unknown: inspect the seat before sending again.
@@ -133,14 +238,14 @@ delivery may be unknown: inspect the seat before sending again.
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
@@ -158,7 +167,7 @@ index e2ffcb3b..6242cd34 100644
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
@@ -282,8 +378,8 @@ capability reads ("Human and reader paths" in `packages/bus/README.md`):
@@ -293,8 +398,8 @@ capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents, trails, the two decision lists and the credential
status. That includes
a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine
@@ -292,25 +301,26 @@ index 5c6a5931..618a2cf1 100644
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index d321b483..2f04be1b 100644
index d321b483..9b50235c 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -372,7 +372,12 @@
@@ -372,7 +372,13 @@
// Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
if (result.delivered && !paused) await refresh();
- } catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); }
+ } catch (err) {
+ // A 4xx is a refusal before anything ran (the Console's, or the board's: see replyToRow), so
+ // nothing was sent. No answer, an unreadable one or a 5xx leaves delivery unknown.
+ const refused = err.status >= 400 && err.status < 500;
+ // nothing was sent. No answer, an unreadable one, a 5xx or a 408 (a proxy can answer it
+ // after forwarding) leaves delivery unknown.
+ const refused = err.status >= 400 && err.status < 500 && err.status !== 408;
+ receipts.set(id, { error: err.message + (refused ? '. Not sent.' : '. Delivery may be unknown; check the seat before sending again.') });
+ }
finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); }
});
document.addEventListener('keydown', e => {
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 3be22af5..29764898 100644
index b22bcf00..ffcc7cf5 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -1,7 +1,8 @@
@@ -350,10 +360,10 @@ index 3be22af5..29764898 100644
+ const said = !r ? '' : r.kind === 'sent'
+ ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>`
+ : r.kind === 'refused'
+ ? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)} (<code>${txt(r.code)}</code>). The draft is kept.</p>`
+ ? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
+ : `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
+ return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
+ <p class="small muted">Goes to the role as a message from the human, recorded by the bus exactly like one sent from the CLI. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</p>
+ <p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</p>
+ ${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
+ <form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
+ <label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
@@ -382,7 +392,7 @@ index 3be22af5..29764898 100644
<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>`;
}
@@ -444,6 +481,11 @@
@@ -455,6 +492,11 @@
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);
@@ -394,7 +404,7 @@ index 3be22af5..29764898 100644
} else {
const d = await decision(get, ref);
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
@@ -454,8 +496,8 @@
@@ -465,8 +507,8 @@
}
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
const base = `#/trail/${kind}/${enc(ref)}`;
@@ -405,7 +415,7 @@ index 3be22af5..29764898 100644
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)}`;
@@ -527,24 +569,30 @@
@@ -538,24 +580,30 @@
if (a === 'settings' && n === 1) return settingsView(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.`);
@@ -438,7 +448,7 @@ index 3be22af5..29764898 100644
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
}
async function render(navigated) {
@@ -677,6 +725,36 @@
@@ -688,6 +736,37 @@
if (m) { const src = $(m.dataset.mirror); src.value = m.value; src.dispatchEvent(new Event('change')); return; }
if (e.target.id === 'palette' || e.target.id === 'mode') for (const s of document.querySelectorAll(`#bus-view select[data-mirror="${e.target.id}"]`)) s.value = e.target.value;
});
@@ -458,14 +468,15 @@ index 3be22af5..29764898 100644
+ } catch { res = null; }
+ // Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
+ // sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
+ // request never reached the Console. Anything else, no answer included, may have been sent.
+ // request never reached the Console. Anything else, no answer included, may have been sent,
+ // and so may a 408: a proxy can answer it after forwarding (Envoy's stream_idle_timeout).
+ if (res?.type === 'opaqueredirect') {
+ receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
+ } else if (res?.ok && SAFE.test(body?.id ?? '')) {
+ receipt = { kind: 'sent', to, id: body.id, at: body.at };
+ if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
+ } else if (res && !res.ok && ((res.status >= 400 && res.status < 500) || body?.sent === false)) {
+ receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message : 'refused' };
+ } else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
+ receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
+ } else receipt = { kind: 'unknown', to };
+ sending = false; paintSend();
+ announce(receipt.kind === 'sent' ? `Sent to ${to}` : receipt.kind === 'refused' ? 'Not sent; draft kept' : 'Outcome unknown; it may have been sent; draft kept');
@@ -588,15 +599,16 @@ index 89327c07..6209f9b9 100644
const file = files.get(path);
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
new file mode 100644
index 00000000..9b57c57c
index 00000000..dd7c5668
--- /dev/null
+++ b/packages/webui/tests/send-browser.test.mjs
@@ -0,0 +1,164 @@
@@ -0,0 +1,195 @@
+// Row 76 (#1570): the Inbox's "Message a role" box in a browser. A send passes only with the
+// proxy's https Origin, which a loopback page can't have, so a script added before the page
+// loads answers /api/config with send on and answers each POST /api/bus/message from a queue,
+// recording what the page sent. Server-side checks are send.test.mjs's. The roles and the
+// message trail come from an in-process broker through its reader session.
+// recording what the page sent. A role's run is ended in the broker to show a role no longer held. Server-side checks are send.test.mjs's. The roles and the
+// message trail come from an in-process broker through its reader session. The same script
+// answers the board's /api/reply from its own queue while one is queued, for the Reply receipt.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
@@ -624,9 +636,13 @@ index 00000000..9b57c57c
+// what fetch gives for the sign-in proxy's redirect under redirect: 'manual' (type opaqueredirect, status 0).
+const STUB = `(() => {
+ const real = window.fetch.bind(window);
+ window.__sends = []; window.__answers = [];
+ window.__sends = []; window.__answers = []; window.__replies = [];
+ window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ if (u === '/api/reply' && window.__replies.length) {
+ const a = window.__replies.shift();
+ return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
+ }
+ if (u === '/api/config' && !location.search.includes('nosend')) {
+ const c = await (await real(url, init)).json();
+ return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
@@ -642,7 +658,7 @@ index 00000000..9b57c57c
+ };
+})();`;
+
+test('the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link', { timeout: 120000 }, async () => {
+test('the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx', { timeout: 120000 }, async () => {
+ const root = mkdtempSync(join(tmpdir(), 'webui-send-browser-'));
+ const store = new Store(root), b0 = new Broker({ store, businesses });
+ for (const role of ['pm', 'coder']) b0.request(b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' });
@@ -665,6 +681,22 @@ index 00000000..9b57c57c
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
+ await b.viewport(1280);
+
+ // The board's Reply: a 4xx was refused before anything ran, so "Not sent."; a 5xx or a 408
+ // leaves delivery unknown. None reaches the board, and the draft stays.
+ await b.navigate(base);
+ await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
+ await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click(), true');
+ await wait('document.querySelector("#reply")');
+ await b.evaluate('document.querySelector("#reply").focus(), true');
+ await b.call('Input.insertText', { text: 'reply draft' });
+ for (const [status, error, said] of [[403, 'refused here', 'failed: refused here. Not sent.'], [502, 'board unreachable', 'failed: board unreachable. Delivery may be unknown; check the seat before sending again.'], [408, 'request timeout', 'failed: request timeout. Delivery may be unknown; check the seat before sending again.']]) {
+ await b.evaluate(`window.__replies.push(${JSON.stringify({ status, body: { error } })}), true`);
+ await b.evaluate('document.querySelector(".reply-form").requestSubmit(), true');
+ await wait(`document.querySelector("#inspection .receipt")?.textContent === ${JSON.stringify(said)}`);
+ assert.equal(await value('#reply'), 'reply draft');
+ }
+ assert.equal(f.captures.length, 0);
+
+ // Without send in /api/config there is no box.
+ await b.navigate(`${base}?nosend#/inbox`);
+ await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && !document.querySelector("#bus-view [aria-busy]")');
@@ -684,17 +716,25 @@ index 00000000..9b57c57c
+ assert.match(await receipt(), /^Not sent to coder: the message is empty/);
+ assert.deepEqual(await sends(), []);
+
+ // The draft survives a refresh, which redraws the form.
+ // The draft, focus and caret survive a refresh, which redraws the form.
+ await set('#send-body', msg);
+ await b.evaluate('document.querySelector("#send-body").dataset.before = "1", true');
+ await b.evaluate('document.querySelector("#refresh").click(), true');
+ await b.evaluate('(()=>{const e=document.querySelector("#send-body");e.dataset.before="1";e.focus();e.setSelectionRange(3,7);document.querySelector("#refresh").click();return true})()');
+ await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
+ assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', msg]);
+ assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#send-body");return {focus:document.activeElement===e,start:e.selectionStart,end:e.selectionEnd}})()'), { focus: true, start: 3, end: 7 });
+
+ // A role that stops being held stays selected, marked, so the draft isn't moved to another role.
+ await set('#send-to', 'pm');
+ b0.endLaunch({ business: 'demo', run: 'pm-run', reason: 'exited' });
+ await b.evaluate('(()=>{document.querySelector("#send-body").dataset.before="1";document.querySelector("#refresh").click();return true})()');
+ await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent, o.selected])'), [['', 'Choose a held role', false], ['coder', 'coder', false], ['pm', 'pm (not held now)', true]]);
+ await set('#send-to', 'coder');
+
+ // A refusal keeps the draft and says why.
+ await answer({ status: 400, body: { error: 'unknown-role', message: 'not sent: the bus refused it (unknown-role)', sent: false } });
+ await send();
+ assert.equal(await receipt(), 'Not sent to coder: not sent: the bus refused it (unknown-role) (unknown-role). The draft is kept.');
+ assert.equal(await receipt(), 'Not sent to coder: the bus refused it (unknown-role). The draft is kept.');
+ assert.equal(await value('#send-body'), msg);
+ const [first] = await sends();
+ assert.deepEqual(first, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to: 'coder', body: msg }), redirect: 'manual' });
@@ -702,13 +742,14 @@ index 00000000..9b57c57c
+ // A 503 the server marked not sent is a refusal too; an unknown outcome and a lost answer are unknown.
+ await answer({ status: 503, body: { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false } });
+ await send();
+ assert.match(await receipt(), /^Not sent to coder: .*\(no-bus-host\)\. The draft is kept\.$/);
+ assert.equal(await receipt(), 'Not sent to coder: the bus refused it (no-bus-host). The draft is kept.');
+ // A redirect is the sign-in proxy's (the Console never redirects this route): not sent.
+ await answer({ redirect: true });
+ await send();
+ assert.equal(await receipt(), 'Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again (sign-in). The draft is kept.');
+ assert.equal(await value('#send-body'), msg);
+ for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }]) {
+ // A 408 too: a proxy can answer it after forwarding.
+ for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }, { status: 408, body: { error: 'request timeout' } }]) {
+ await answer(a);
+ await send();
+ assert.match(await receipt(), /^Outcome unknown: the message to coder may have been sent\. Nothing was sent again, and the draft is kept\./, JSON.stringify(a));
@@ -717,7 +758,7 @@ index 00000000..9b57c57c
+ }
+ // Each Send was one POST; none was repeated after the unknown ones.
+ await new Promise(r => setTimeout(r, 500));
+ assert.equal((await sends()).length, 6);
+ assert.equal((await sends()).length, 7);
+
+ // While a send is pending, Send is disabled and a second press sends nothing; text typed
+ // meanwhile is kept when the first one succeeds.
@@ -727,18 +768,18 @@ index 00000000..9b57c57c
+ assert.equal(await text('#send-form [data-send]'), 'Sending…');
+ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
+ await set('#send-body', 'A second thought');
+ assert.equal((await sends()).length, 7);
+ assert.equal((await sends()).length, 8);
+ await b.evaluate('window.__release(), true');
+ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
+ assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`));
+ assert.equal(await value('#send-body'), 'A second thought');
+ assert.equal((await sends()).length, 7);
+ assert.equal((await sends()).length, 8);
+
+ // A plain success clears the text and keeps the role.
+ await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
+ await send();
+ assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']);
+ assert.equal(JSON.parse((await sends())[7].body).body, 'A second thought');
+ assert.equal(JSON.parse((await sends())[8].body).body, 'A second thought');
+
+ // The receipt links the message's trail, which the bus reads.
+ assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`);
@@ -747,10 +788,11 @@ index 00000000..9b57c57c
+ assert.equal(await text('#bus-view h1'), `Trail for message ${seeded.id.slice(0, 8)}`);
+ assert.match(await text('#bus-view'), /Seeded message from the human/);
+ assert.equal(await b.evaluate('document.querySelector("#bus-view a[href=\'#/inbox\']")?.textContent'), 'Back to the Inbox');
+ assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/inbox');
+ await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`);
+ await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
+ assert.match(await text('#bus-view'), /No message with id no-such-message\./);
+ assert.equal((await sends()).length, 8);
+ assert.equal((await sends()).length, 9);
+ } finally {
+ await b.close(); await close(web); await f.close(); store.close();
+ rmSync(root, { recursive: true, force: true });
@@ -758,10 +800,10 @@ index 00000000..9b57c57c
+});
diff --git a/packages/webui/tests/send.test.mjs b/packages/webui/tests/send.test.mjs
new file mode 100644
index 00000000..706e8f43
index 00000000..11cdb631
--- /dev/null
+++ b/packages/webui/tests/send.test.mjs
@@ -0,0 +1,301 @@
@@ -0,0 +1,305 @@
+// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console.
+// Only with the preview flags; POST /api/bus/message needs the header on this request, its own
+// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker
@@ -957,9 +999,10 @@ index 00000000..706e8f43
+ assert.equal(reply.status, 200);
+ // Values go to the broker as given, whatever their type: it is the one that refuses them.
+ assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200);
+ assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }]);
+ assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body']]);
+ assert.equal(lines.length, 3);
+ assert.equal((await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200);
+ assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]);
+ assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]);
+ assert.equal(lines.length, 4);
+ assert.match(lines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" id=msg-0001$/);
+ assert.match(lines[2], / to=none id=msg-0001$/);
+ assert.equal(lines.some(l => l.includes('secret-body-text')), false);
@@ -974,7 +1017,10 @@ index 00000000..706e8f43
+ assert.equal(lines.length, 1);
+ assert.equal(lines[0].includes('\n'), false);
+ assert.match(lines[0], / error=unknown-role$/);
+ assert.ok(lines[0].length < 200);
+ assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`));
+ // The cut is at 64 characters: a 65-character role loses its last one.
+ await post(port, { body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) });
+ assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`));
+});
+
+test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => {
@@ -991,7 +1037,7 @@ index 00000000..706e8f43
+ [new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
+ [new BusReadError('invalid-response'), 503, 'outcome-unknown', 'unknown'],
+ [new BusReadError('response-too-large'), 503, 'outcome-unknown', 'unknown'],
+ [new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), 503, 'outcome-unknown', 'unknown'],
+ [Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { code: 'ENOENT' }), 503, 'outcome-unknown', 'unknown'],
+ [{ id: '' }, 503, 'outcome-unknown', 'unknown'],
+ [null, 503, 'outcome-unknown', 'unknown'],
+ [{ id: '../x' }, 503, 'outcome-unknown', 'unknown'],
@@ -1,9 +1,9 @@
c6cf1714407803d21a5757f39333a396c76abf582da0df51aeee4294e00cff06 packages/webui/README.md
911feb19e9b16fa4f163d77ee10c33270049ea60d16cabcd4197cd8a8847eaf7 packages/webui/README.md
a0661abb7de79ce2854884c99f7d8db114d8028d99048e7387a9b2f8534cd422 packages/webui/src/bus.mjs
c8f9845c66793b0516d5f356f6be9f8ae2d46c40fdd044945aac2a606d024bee packages/webui/src/cli.mjs
a259dd523625a83237ebc98d0361ff6de1732729a661f9e0f37492f99e3b3f60 packages/webui/src/serve.mjs
6ec0ff037d91ba52cce3361b079131d255df8484bcc6e924956731d2b2f86de1 packages/webui/src/public/app.js
643178391c4e5dc2ac05a9c1d2972dd374bf6f692a5f28717ec0289d74a64a91 packages/webui/src/public/bus.js
7dfceeaf485bc9b636fb12c7bc5a0f12ca74d6b43c351feaf2d335f527f31ee9 packages/webui/tests/send.test.mjs
fdf0d30c097ab425fe8426f940892b7422a00ab5d6b7d30cefa0c4c5feafaa35 packages/webui/tests/send-browser.test.mjs
8f627ccde5775281aa7676b8495d55bcb65a5f32f6a2bb53f1a313afb28ccd8f packages/webui/src/public/app.js
78d75b88a89a2bbd83a264e8b8a5da739edb0b19dd7a8cfbbc0f18fd4895fceb packages/webui/src/public/bus.js
8401bd8f650dadf07fadf4f1304d8f509f706441763d870e48fd14215c9a7a22 packages/webui/tests/send.test.mjs
97304808bab8eb27fde756081863ff56e932f7021fc151165db72ac2a7a83c81 packages/webui/tests/send-browser.test.mjs
17cb39097414bd685d68305dd5e402fd645020695f3dee86cd6bb567dd410eff packages/webui/tests/shell.test.mjs
+15 -15
View File
@@ -1,6 +1,6 @@
== a send, and a reply to the coder
to coder 200 {"id":"8f66630d-cb38-4879-8119-fb3defd532c8","at":"2026-10-12T00:40:48.392Z"}
to coder, in_reply_to the coder's message 200 {"id":"3a35d7b4-2020-464e-9bc3-5ee870c8b53f","at":"2026-10-12T00:40:49.041Z"}
to coder 200 {"id":"9db1d4eb-2abd-4c13-a388-2ed3d9503c07","at":"2026-10-12T02:37:11.862Z"}
to coder, in_reply_to the coder's message 200 {"id":"c4b38d2a-34df-4dea-b31b-897f7de01777","at":"2026-10-12T02:37:11.877Z"}
== refused before the bus
no sign-in header (401, no log line) 401 {"error":"sign-in required"}
loopback Host with the header 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
@@ -20,19 +20,19 @@ unknown, not retried 503 {"error":"outcome-unknown","
== without --send
POST is read-only 403 {"error":"read-only","message":"the Console preview is read-only"}
== what the broker holds
8f66630d from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
3a35d7b4 from=human/human-cli to=coder class=INFO in_reply_to=0536126f-c54a-40a5-999d-c7fe1c01d9a2 body="refactor"
9db1d4eb from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
c4b38d2a from=human/human-cli to=coder class=INFO in_reply_to=8c236a3d-c415-4252-818c-015cdd81d185 body="refactor"
pm receives 1 message(s) from the lost-answer send: ["Lost answer test"]
== the Console log, one line per send past sign-in
2026-10-12T00:40:48.392Z send user="jason" to="coder" id=8f66630d-cb38-4879-8119-fb3defd532c8
2026-10-12T00:40:49.041Z send user="jason" to="coder" id=3a35d7b4-2020-464e-9bc3-5ee870c8b53f
2026-10-12T00:40:49.057Z send user="jason" to=none error=cross-origin request refused
2026-10-12T00:40:49.058Z send user="jason" to=none error=cross-site request refused
2026-10-12T00:40:49.061Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.066Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.072Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.281Z send user="jason" to="auditor" error=unknown-role
2026-10-12T00:40:49.535Z send user="jason" to="coder" error=invalid-request
2026-10-12T00:40:49.802Z send user="jason" to="coder" error=message-not-found
2026-10-12T00:40:50.173Z send user="jason" to="pm" error=outcome-unknown
2026-10-12T02:37:11.862Z send user="jason" to="coder" id=9db1d4eb-2abd-4c13-a388-2ed3d9503c07
2026-10-12T02:37:11.877Z send user="jason" to="coder" id=c4b38d2a-34df-4dea-b31b-897f7de01777
2026-10-12T02:37:11.881Z send user="jason" to=none error=cross-origin request refused
2026-10-12T02:37:11.882Z send user="jason" to=none error=cross-site request refused
2026-10-12T02:37:11.883Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.885Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.886Z send user="jason" to=none error=invalid-request
2026-10-12T02:37:11.892Z send user="jason" to="auditor" error=unknown-role
2026-10-12T02:37:11.899Z send user="jason" to="coder" error=invalid-request
2026-10-12T02:37:11.905Z send user="jason" to="coder" error=message-not-found
2026-10-12T02:37:11.913Z send user="jason" to="pm" error=outcome-unknown
a body in the log: false
+15
View File
@@ -11,6 +11,7 @@ OUT = R + '/out/mut'
SERVE = 'packages/webui/src/serve.mjs'
BUS = 'packages/webui/src/bus.mjs'
PAGE = 'packages/webui/src/public/bus.js'
APP = 'packages/webui/src/public/app.js'
TESTS = ['node', '--test', 'packages/webui/tests/*.test.mjs']
MUTANTS = [
@@ -36,6 +37,20 @@ MUTANTS = [
('page-redirect-unknown', PAGE, [(" if (res?.type === 'opaqueredirect') {", " if (false) {")]),
('page-follows-redirect', PAGE, [(", cache: 'no-store', redirect: 'manual' });", ", cache: 'no-store' });")]),
('page-trail-any-id', PAGE, [(" if (!of(tr, 'messages').some(m => m.id === ref)) return notFound(", " if (false) return notFound(")]),
('reply-4xx-unknown', APP, [("const refused = err.status >= 400 && err.status < 500 && err.status !== 408;", "const refused = false;")]),
('reply-5xx-refused', APP, [("const refused = err.status >= 400 && err.status < 500 && err.status !== 408;", "const refused = err.status >= 400;")]),
('page-no-caret', PAGE, [("el.setSelectionRange(k.caret[0], k.caret[1]); ", "")]),
('page-caret-not-kept', PAGE, [("caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null", "caret: null")]),
('page-code-twice', PAGE, [("${r.message.includes(`(${r.code})`) ? '' : ` (<code>", "${false ? '' : ` (<code>")]),
('page-keeps-not-sent-prefix', PAGE, [(".replace(/^not sent: /, '')", "")]),
('page-408-refused', PAGE, [(" && res.status !== 408)", ")")]),
('reply-408-refused', APP, [(" && err.status !== 408;", ";")]),
('page-no-field-focus', PAGE, [(" el.focus({ preventScroll: true });\n if (k.caret", " if (k.caret")]),
('page-not-held-dropped', PAGE, [("const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;", "const roles = sendRoles ? [...sendRoles] : null;")]),
('page-inbox-not-current', PAGE, [(" || route.startsWith('/trail/message')", "")]),
('send-reply-truthy', SERVE, [(" if ('in_reply_to' in value) args.in_reply_to", " if (value.in_reply_to) args.in_reply_to")]),
('outcome-plain-error-code', BUS, [("const code = err instanceof BusReadError ? err.code : 'outcome-unknown';", "const code = err?.code ?? 'outcome-unknown';")]),
('send-log-cut-65', SERVE, [("JSON.stringify(to.slice(0, 64))", "JSON.stringify(to.slice(0, 65))")]),
]
def run(cmd):
+23 -8
View File
@@ -1,3 +1,4 @@
2026-10-12T01:54:19Z
baseline pass
send-header-unchecked killed: ['a send without the signed-in user is 401 and is neither sent nor logged, loopback included']
send-origin-unchecked killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
@@ -13,12 +14,26 @@ route-open-without-send killed: ['without --send the route is read-only like eve
send-opens-writes killed: ['with --send every other write is still 403 read-only, and nothing reaches the board or bus']
outcome-all-definite killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', 'through the human transport the request is exactly the business, message.send and the keys']
status-unknown-role-502 killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', 'against a real broker: the message is written once as a human send, and its refusals map', 'through the human transport the request is exactly the business, message.send and the keys']
page-no-pending-guard killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-clears-edited-text killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-5xx-refusal-unknown killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-retries-unknown killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-redirect-unknown killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-follows-redirect killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-trail-any-id killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
page-no-pending-guard killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-clears-edited-text killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-5xx-refusal-unknown killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-retries-unknown killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-redirect-unknown killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-follows-redirect killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-trail-any-id killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
reply-4xx-unknown killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
reply-5xx-refused killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-no-caret killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-caret-not-kept killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-code-twice killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-keeps-not-sent-prefix killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-408-refused killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
reply-408-refused killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-no-field-focus killed: ['Queue, Business and Settings in their five states, with no move button', 'the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-not-held-dropped killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
page-inbox-not-current killed: ['the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx']
send-reply-truthy killed: ['a send passes exactly to, body and in_reply_to, answers the id, and logs it']
outcome-plain-error-code killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried']
send-log-cut-65 killed: ['a crafted role name cannot forge a log line']
worktree unchanged after: True
summary 21 of 21 killed
summary 35 of 35 killed