filbert: row 95 build packet (#1589), manifest 246c0557
Candidate on base a3f6e9e6, not committed here. Gate green 21:27:35Z-21:30:19Z;
mutants 30/35 killed, 5 equivalent guards explained in build.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# Row 95 (#1589): the Board shows the stack's seats, and a sign-out line
|
||||
|
||||
Filbert, 2026-10-12. Brief: `docs/plans/2026-10-12_pm-test-fixes.md`, "The
|
||||
Console's Board shows the stack's seats and a sign-out" (blob ae867dfa,
|
||||
unchanged). Darkwing reviews. Sage commits after the gate. Nothing in the
|
||||
candidate is committed, staged or pushed.
|
||||
|
||||
Base a3f6e9e6. Nothing has landed on the manifest paths since: `git diff --stat
|
||||
a3f6e9e6..f9db19d7` on `packages/webui/src/public`, `packages/webui/tests`,
|
||||
`packages/webui/README.md` and `serve.mjs` is empty. `build.patch` applies at
|
||||
a3f6e9e6 with `git apply --index`, and `candidate-manifest.sha256` pins all 10
|
||||
files after the patch. `serve.mjs` is unchanged: the held roles come from the
|
||||
existing `/api/bus/agents` read.
|
||||
|
||||
## What ships
|
||||
|
||||
- **Held roles first.** The Board's first section, "Held roles", lists each
|
||||
role a run holds on the bus, from the `agents` read: role, state (with the
|
||||
working count), attention, address, holder run with its harness, and held
|
||||
since. A claim with no address shows "no address". It's read with the Inbox
|
||||
count on every Board render (the 10 s poll and Refresh). A refusal shows no
|
||||
roles; any other failure keeps the last read, labelled with its time, as the
|
||||
bus pages do. Focus stays on the same Message button or Retry across the
|
||||
repaint.
|
||||
- **Message.** Each row has a Message button when the Console takes a send
|
||||
(`send` in `/api/config`). It sets the form's role, goes to `#/inbox` and puts
|
||||
the caret in the message box. Without `--send` there is no button, and the
|
||||
section says messaging needs a Console started with `--send`.
|
||||
- **Show machine sessions.** A checkbox in the Board's head, off by default.
|
||||
While it's off the page never requests `/api/board` (no first read and no
|
||||
poll), and the projects tree, Waiting on you, Seen, All sessions and the
|
||||
board banners are hidden. Turning it off drops the scan, the selection and
|
||||
any open conversation, so turning it on starts from a fresh read. The choice
|
||||
is kept in `localStorage` as `mosaic-console-board` = `{"machine":bool}`.
|
||||
- **Signed out.** Every Console fetch in `app.js` and `bus.js` passes
|
||||
`redirect: 'manual'`: the board and conversation reads, the board's Seen and Reply POSTs,
|
||||
`/api/config` (both), the bus and module reads, and the send. The first
|
||||
`opaqueredirect` dispatches `mosaic-signed-out`. `app.js` sets
|
||||
`body.signed-out`, and both scripts then:
|
||||
- stop their timers and make no further Console fetch (each fetch helper
|
||||
checks first, and Refresh and Pause are disabled);
|
||||
- drop everything drawn from a read: the sessions, the inspector, any
|
||||
conversation, the held roles, the kept bus reads and the bus page;
|
||||
- show one line, "Signed out. Reload the page to sign in.", focused only if
|
||||
focus was lost, never pulled out of the send form.
|
||||
|
||||
The send form stays with its draft and its receipt. A send answered with a
|
||||
redirect says "Not sent … the sign-in proxy redirected it …; the draft is
|
||||
kept". A send after sign-out says "Not sent … signed out; reload the page to
|
||||
sign in again" and fetches nothing. A read in flight at the sign-out is
|
||||
dropped when it lands.
|
||||
- **README**: a new section, "Board: held roles and signing out (row 95,
|
||||
#1589)", the "Not sent" bullet's redirect case, and the new test file in
|
||||
Verify.
|
||||
|
||||
## Tests
|
||||
|
||||
- New `tests/board-held.test.mjs`, two browser tests against an in-process
|
||||
broker with pm (address `pm-run`) and coder (no address) holding claims:
|
||||
1. Held roles with machine sessions off: the two rows, "no address", the
|
||||
count, the Message labels; the toggle unchecked, no `/api/board` request;
|
||||
on, the sessions show and the setting is kept across a reload; off, the
|
||||
sessions go and a reload reads no board; Message for each role lands in
|
||||
`#/inbox` with that role and the caret in the message; with no send, no
|
||||
Message button. Every request asked for `redirect: 'manual'`.
|
||||
2. A redirect answer on a Board read (`/api/board` on Refresh), a bus read
|
||||
(`/api/bus/agents` on Refresh), a conversation read (`/api/conversations`
|
||||
from History), a board POST (`/api/seen` from the inspector's Seen), a
|
||||
module read (`/api/queue` on the Queue page) and a send. Each time: the
|
||||
line once, no NetworkError, "did not answer" or "not read" text (the
|
||||
footer stays visible), no sessions or held roles left, Refresh disabled,
|
||||
and no further request on a route change, Refresh, the toggle twice or
|
||||
the poll. The send keeps its draft and form.
|
||||
- `send-browser.test.mjs`: after its redirect-send case, the page is signed
|
||||
out; the test checks the line and that a further send fetches nothing, then
|
||||
reloads before its remaining cases.
|
||||
- `browser-edge.test.mjs`: with storage denied, the toggle starts off and still
|
||||
turns on.
|
||||
- `browser.mjs`: `browser({ machine })`, default on (see choice 5).
|
||||
- `shell.test.mjs`: the 1440 short-page check is 1000 px tall (choice 6).
|
||||
|
||||
## Choices
|
||||
|
||||
1. **"Kept in the page's settings" is `localStorage`.** The other Board
|
||||
filters (Hide offline, Hide seen) aren't kept across a reload, so "like the
|
||||
other Board filters" can't mean their storage. I read it as: a checkbox with
|
||||
the other filters, kept the way the page keeps its appearance settings
|
||||
(`mosaic-console-appearance`). Refused storage starts it off.
|
||||
2. **No board read while off.** Hiding the sessions while still polling the
|
||||
scan would spend a 1.3 s read every 10 s on data nobody sees. Off means no
|
||||
request.
|
||||
3. **Message only with `--send`.** The brief says Message opens the send
|
||||
form; the form exists only on a Console that takes a send. A button that
|
||||
leads to a page saying "this Console only reads" is worse than a line on the
|
||||
Board saying so.
|
||||
4. **The send form stays when signed out.** "Doesn't show stale data as
|
||||
current" covers the reads. The draft is the person's own text, so it stays,
|
||||
and a further Send says why it wasn't sent instead of fetching. A board
|
||||
Reply in the inspector goes with the rest of the Board: its draft isn't
|
||||
shown again (the README says so).
|
||||
5. **`browser()` defaults to machine sessions on.** The existing suites open 16
|
||||
browsers in 11 test files, and most of them read the board scan. Seeding
|
||||
the setting is one line in the harness; the alternative was a click in each. Row 95's own first test passes
|
||||
`{ machine: false }` to see a first visit.
|
||||
6. **The shell check is 1000 px tall.** It asserts a short Board doesn't
|
||||
scroll. With the held roles added, its two-session Board is 967 px, so 900
|
||||
failed. The 420 px case still checks a scrolling page.
|
||||
7. **The in-flight read race.** Test 2 found that a `/api/board` read in
|
||||
flight when a bus read signed the page out landed afterwards and redrew the
|
||||
sessions. `accept()` and `error()` now return when signed out; mutants 10
|
||||
and 11 cover them.
|
||||
8. **Overlap with row 77.** Row 77 (#1571, round 2 in review) also edits
|
||||
`packages/webui/README.md`, in other sections. Whichever lands second needs
|
||||
a rebase of that file.
|
||||
|
||||
## Gate
|
||||
|
||||
Run in my worktree (`a3f6e9e6` plus the candidate), sequential,
|
||||
`DOCKER_HOST` at a dead socket, 2026-10-12T21:27:35Z–21:30:19Z
|
||||
(`gate/summary.txt`):
|
||||
|
||||
- webui 86/0, control-board 124/0;
|
||||
- every `scripts/test-*.sh` exited 0: auth 15, conductor 17, config 24,
|
||||
discord 66, extension-package 18, foundation 44, queue 27, release 4 and
|
||||
task 26. test-queue skipped its canonical-root part (a worktree), and
|
||||
test-release skipped its Docker cases.
|
||||
|
||||
The first gate (20:22:51Z–20:25:43Z, same counts, `gate1/`) ran before the
|
||||
first mutant round. That round left 9 of 35 alive (`mut/out-round1.txt`). Four
|
||||
were gaps:
|
||||
- no test redirected a conversation read, a board POST or a module read, so
|
||||
dropping `redirect: 'manual'` from `getJSON`, from `api`'s POST or from
|
||||
`fetchApi` survived;
|
||||
- dropping `error()`'s signed-out guard survived. Without it, a redirected
|
||||
Board read writes "Board not read: the read failed" into the footer, which
|
||||
stays visible when signed out, and the test didn't look there.
|
||||
|
||||
Test 2 now redirects `/api/conversations` from History, `/api/seen` from the
|
||||
inspector's Seen and `/api/queue` on the Queue page, and checks the page for
|
||||
"not read" and "last known data". `app.js` and `bus.js` didn't change, and
|
||||
`board-held.test.mjs` is the only file whose digest moved.
|
||||
|
||||
**Mutants** (`mut/mutants.py`, `mut/out.txt`), round 2,
|
||||
21:03:18Z–21:27:35Z: 35 edits to `app.js` and `bus.js`, each matching once,
|
||||
each run against board-held, send-browser, browser-edge and browser. 30 were
|
||||
killed and both files were restored byte for byte. The 5 survivors are
|
||||
redundant guards behind another check that holds:
|
||||
|
||||
| Survivor | Why nothing changes |
|
||||
| --- | --- |
|
||||
| 13 `schedule` polls with the toggle off | `refresh()` returns on `!machine` (12, killed) |
|
||||
| 14 `refresh()` without its signed-out guard | `api()` throws before fetching, and `error()` returns on `err.signedOut` (11, killed) |
|
||||
| 27 bus `schedule` polls signed out | `render()` returns on `out()` (26, killed) |
|
||||
| 28 Board render without its signed-out return | the listener's `gen++` returns the render before any paint; only `last` is refilled, and nothing reads it again |
|
||||
| 29 bus view render without its signed-out return | the same `gen++` check, before the view is drawn |
|
||||
|
||||
I kept them, so that no single check stands between a signed-out page and a
|
||||
fetch or a stale paint. Mutants 10 and 11 (choice 7) are killed.
|
||||
|
||||
The mutant 19 run (toggle on reads nothing) times out at 900 s in both rounds.
|
||||
The run's timeout killed only `node --test`, so its file runner and browser
|
||||
were left, one per round. Darkwing saw them, and I killed both by PID at
|
||||
21:23:55Z. The `mutants.py` here starts each run in its own process group and
|
||||
kills the group on a timeout. That's the only change from the copy that
|
||||
produced `out.txt`.
|
||||
|
||||
## Not tested here
|
||||
|
||||
- The real Firefox NetworkError: the tests stub `fetch` to answer an
|
||||
`opaqueredirect`, which is what Chromium and Firefox both return under
|
||||
`redirect: 'manual'`. Live, a pod replacement at the edge is the check.
|
||||
- Live after the Console restart Mos approves (decision 109).
|
||||
@@ -0,0 +1,790 @@
|
||||
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||||
index be26d050..cf91674c 100644
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -186,7 +186,7 @@ On the page:
|
||||
- Not sent shows the broker's code and keeps the draft. A redirect counts
|
||||
as not sent: the Console never redirects this route, so only the sign-in
|
||||
proxy does, when its session has expired. The page asks for a reload to
|
||||
- sign in again.
|
||||
+ sign in again, and signs out (row 95, below).
|
||||
- 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. A 408 counts as
|
||||
@@ -312,6 +312,47 @@ there are others. The page says what each state means. It writes nothing;
|
||||
the server passes the rows through unchanged. How the bus derives the state
|
||||
and its limits: `packages/bus/README.md`, "Agent state".
|
||||
|
||||
+## Board: held roles and signing out (row 95, #1589)
|
||||
+
|
||||
+The Board (`#/`) opens with **Held roles**: each role a run holds on the
|
||||
+bus, from the same bus `agents` read as `#/agents`, including a claim with
|
||||
+no address ("no address"). Each row shows the role, its state and attention
|
||||
+(as in "Agent state"), the address, the holding run with its harness, and
|
||||
+since when. With send on (`--send`, "Message a role"), each row has a
|
||||
+Message action: it opens `#/inbox` with the send form addressed to that role
|
||||
+and the caret in the message. Without send there is no action, and the
|
||||
+section says so. A refused or failed first read says why and shows no roles
|
||||
+rather than a guess; a failed later read keeps the last roles under a stale
|
||||
+warning.
|
||||
+
|
||||
+The machine scan's sessions (Waiting on you, Seen, All sessions and the
|
||||
+project list) sit behind **Show machine sessions**, off by default. While
|
||||
+it is off the page makes no `/api/board` request and polls nothing for it.
|
||||
+The choice is kept in this browser's local storage under
|
||||
+`mosaic-console-board` (`{"machine":true}`), like the appearance; where
|
||||
+storage is refused it starts off on each load. Turning it off drops the
|
||||
+board data and closes an open conversation.
|
||||
+
|
||||
+Every Console fetch the page makes uses `redirect: 'manual'`. The Console
|
||||
+never redirects, so a redirect answer (`opaqueredirect`) is the sign-in
|
||||
+proxy's: its session has ended. The first one, on a board read, a bus read,
|
||||
+a page read or a send, signs the page out:
|
||||
+
|
||||
+- One line says "Signed out. Reload the page to sign in." No NetworkError,
|
||||
+ failure banner or stale warning is shown for it.
|
||||
+- Everything drawn from a Console read is removed (the board, the held
|
||||
+ roles, the bus views, an open conversation and the inspector), because
|
||||
+ none of it can be refreshed. A read that was in flight is dropped when it
|
||||
+ lands.
|
||||
+- Refresh and Pause are disabled; nothing polls and no route change reads.
|
||||
+- The send form, if open, keeps its draft and receipt so the text isn't
|
||||
+ lost; a further Send is refused in the page ("signed out"). A board
|
||||
+ Reply that meets the redirect is not sent, and its inspector closes with
|
||||
+ the rest of the board, so its draft isn't shown again.
|
||||
+- Reloading the page goes through the sign-in proxy again.
|
||||
+
|
||||
+How long a sign-in lasts is the edge's setting (ops-01), not the Console's.
|
||||
+
|
||||
## Shell (row 53, #1542)
|
||||
|
||||
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
|
||||
@@ -501,6 +542,18 @@ 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.
|
||||
|
||||
+Row 95's tests (`board-held.test.mjs`) seed an in-process broker with one
|
||||
+claim that has an address and one that has none. They check the held roles
|
||||
+with the machine sessions off by default and no `/api/board` request, the
|
||||
+toggle on and off and its persistence across reloads, Message addressing
|
||||
+the send form to each role, no action without send, and that every fetch
|
||||
+asked for `redirect: 'manual'`. Then a redirect answer on a board read, an
|
||||
+`agents` read and a send: one line, no NetworkError, nothing stale left, no
|
||||
+further request on a route change, Refresh or the toggle, and the draft
|
||||
+kept. `send-browser.test.mjs` also checks the sign-out after its redirect
|
||||
+receipt and that a reload works again. Since the held roles lead the Board,
|
||||
+the shell test's no-scroll check at 1440px uses a 1000px-tall window.
|
||||
+
|
||||
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.
|
||||
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
|
||||
index 9b50235c..3587fd71 100644
|
||||
--- a/packages/webui/src/public/app.js
|
||||
+++ b/packages/webui/src/public/app.js
|
||||
@@ -8,6 +8,15 @@
|
||||
const awaiting = new Map();
|
||||
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
|
||||
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
|
||||
+ // Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
|
||||
+ // page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
|
||||
+ let machine = false;
|
||||
+ try { machine = JSON.parse(localStorage.getItem('mosaic-console-board'))?.machine === true; } catch {}
|
||||
+ // Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
|
||||
+ // The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
+ const out = () => document.body.classList.contains('signed-out');
|
||||
+ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
+ const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
|
||||
@@ -97,7 +106,9 @@
|
||||
const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
|
||||
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
|
||||
async function getJSON(path) {
|
||||
- const res = await fetch(path, { cache: 'no-store' });
|
||||
+ if (out()) throw signedOut();
|
||||
+ const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
@@ -281,6 +292,7 @@
|
||||
}
|
||||
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
|
||||
function fresh() {
|
||||
+ if (!machine) { $('fresh-board').textContent = 'Machine sessions hidden'; return; }
|
||||
const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null;
|
||||
if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||||
const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||||
@@ -288,12 +300,15 @@
|
||||
}
|
||||
setInterval(() => { if (data) fresh(); }, 5000);
|
||||
async function api(path, value) {
|
||||
- const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
|
||||
+ if (out()) throw signedOut();
|
||||
+ const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||
return result;
|
||||
}
|
||||
function accept(value) {
|
||||
+ if (out()) return; // a read that was in flight when the page signed out
|
||||
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
|
||||
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
|
||||
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
||||
@@ -306,6 +321,7 @@
|
||||
// its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
|
||||
// and says why, so a later failure never brings back old rows.
|
||||
function error(err) {
|
||||
+ if (err.signedOut || out()) return;
|
||||
const refused = err.status === 403, focused = document.activeElement !== document.body;
|
||||
failed = refused ? 'refused' : true; $('error').hidden = false;
|
||||
if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
|
||||
@@ -327,13 +343,36 @@
|
||||
// Clearing the board or closing a conversation can remove what had focus: give it to the page.
|
||||
if (focused && document.activeElement === document.body) $('main').focus();
|
||||
}
|
||||
- function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
|
||||
+ function schedule() { clearTimeout(timer); if (!paused && machine && !out()) timer = setTimeout(refresh, 10000); }
|
||||
+ // A board answer that arrives after the toggle went off is dropped, not drawn.
|
||||
async function refresh() {
|
||||
- if (busy) return;
|
||||
+ if (busy || !machine || out()) return;
|
||||
busy = true; $('refresh').disabled = true;
|
||||
- try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
|
||||
- finally { busy = false; $('refresh').disabled = false; schedule(); }
|
||||
+ try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
|
||||
+ finally { busy = false; $('refresh').disabled = out(); schedule(); }
|
||||
}
|
||||
+ // With the toggle off, nothing from the scan stays on the page: the next time it goes on, the board
|
||||
+ // starts from a fresh read rather than showing an old scan as current.
|
||||
+ function hideMachine() {
|
||||
+ clearTimeout(timer);
|
||||
+ if (conv) closeConversation();
|
||||
+ data = null; selected = null; project = null; failed = false; inspect();
|
||||
+ $('error').hidden = true; $('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
||||
+ $('projects').innerHTML = '<p class="muted">Machine sessions hidden.</p>';
|
||||
+ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
+ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||||
+ $('footer').textContent = 'Mosaic Stack';
|
||||
+ $('status').textContent = 'Machine sessions hidden. Turn on Show machine sessions to read this machine\'s board scan.';
|
||||
+ fresh();
|
||||
+ }
|
||||
+ function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
|
||||
+ $('show-machine').onchange = e => {
|
||||
+ machine = e.target.checked; showMachine();
|
||||
+ try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}
|
||||
+ if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
|
||||
+ };
|
||||
+ showMachine();
|
||||
+ if (!machine) hideMachine();
|
||||
$('refresh').onclick = refresh;
|
||||
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
|
||||
$('hide-offline').onchange = $('hide-seen').onchange = render;
|
||||
@@ -373,6 +412,7 @@
|
||||
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) {
|
||||
+ if (err.signedOut) { receipts.set(id, { error: `Signed out: ${err.message}. Not sent.` }); return; }
|
||||
// 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, a 5xx or a 408 (a proxy can answer it
|
||||
// after forwarding) leaves delivery unknown.
|
||||
@@ -388,5 +428,20 @@
|
||||
const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
|
||||
if (next !== undefined) { e.preventDefault(); buttons[next]?.focus(); }
|
||||
});
|
||||
- api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(error).finally(refresh);
|
||||
+ // Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||||
+ // line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||||
+ document.addEventListener('mosaic-signed-out', () => {
|
||||
+ if (out()) return;
|
||||
+ document.body.classList.add('signed-out');
|
||||
+ clearTimeout(timer); convGen++;
|
||||
+ if (conv) { $('conv-log').replaceChildren(); conv = null; showConversation(false); }
|
||||
+ data = null; selected = null; project = null; inspect();
|
||||
+ for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
+ $('refresh').disabled = $('pause').disabled = true;
|
||||
+ const line = $('signed-out');
|
||||
+ line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
+ // Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||||
+ setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
+ });
|
||||
+ api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
||||
index 8b4bcf43..e5a8e179 100644
|
||||
--- a/packages/webui/src/public/bus.js
|
||||
+++ b/packages/webui/src/public/bus.js
|
||||
@@ -24,24 +24,32 @@
|
||||
// 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; } }
|
||||
+ // Row 95 (#1589): every Console fetch takes `redirect: 'manual'`. A redirect comes only from the
|
||||
+ // sign-in proxy, so the session ended: app.js's listener sets body.signed-out and draws the line,
|
||||
+ // and from then on no read is made. A signed-out read throws code `signed-out`, which no view shows.
|
||||
+ const out = () => document.body.classList.contains('signed-out');
|
||||
+ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
+ const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
+ if (out()) throw signedOut();
|
||||
let res, body;
|
||||
- try {
|
||||
- res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
|
||||
- body = await res.json();
|
||||
- } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
+ try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||||
+ catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
+ try { 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 };
|
||||
}
|
||||
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
|
||||
// error carries the module's own message, which the server has already redacted.
|
||||
async function fetchApi(path) {
|
||||
+ if (out()) throw signedOut();
|
||||
+ const unreachable = () => Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true });
|
||||
let res, body;
|
||||
- try {
|
||||
- res = await fetch(`/api/${path}`, { cache: 'no-store' });
|
||||
- body = await res.json();
|
||||
- } catch { throw Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true }); }
|
||||
+ try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
+ try { body = await res.json(); } catch { throw unreachable(); }
|
||||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||||
return body;
|
||||
}
|
||||
@@ -263,7 +271,7 @@
|
||||
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
|
||||
const r = receipt;
|
||||
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>${repliesHtml()}`
|
||||
+ ? `<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>${out() ? '' : repliesHtml()}`
|
||||
: r.kind === 'refused'
|
||||
? `<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>`;
|
||||
@@ -407,6 +415,38 @@
|
||||
const d = asks[0];
|
||||
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
|
||||
}
|
||||
+ // Row 95 (#1589): the Board's first section, the stack's seats: each held role from the bus
|
||||
+ // `agents` read, a claim with no address included. Message opens the send form addressed to the
|
||||
+ // role, so it shows only where a send can pass (`send` in /api/config). A refusal keeps nothing;
|
||||
+ // any other failure keeps the last read, labelled with its time, as the bus pages do.
|
||||
+ let heldErr = null, heldHtml = null, focusSend = false;
|
||||
+ function heldTable(claims) {
|
||||
+ const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
|
||||
+ const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.holder_run)}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
+ return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
+ }
|
||||
+ function paintHeld(err) {
|
||||
+ heldErr = err;
|
||||
+ const box = $('held'), r = last.get('agents');
|
||||
+ let html;
|
||||
+ if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
|
||||
+ else if (!r) return; // not read yet: the skeleton stays
|
||||
+ else {
|
||||
+ const claims = r.rows;
|
||||
+ html = `${err ? `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} These are the roles read before, ${when(r.at)}. Nothing was changed.</p>${retry}</div>` : ''}
|
||||
+ ${claims.length ? heldTable(claims) : empty('No role is held on the bus right now.', 'A role shows here while a run holds its claim on the bus. Machine sessions are under Show machine sessions.')}
|
||||
+ <p class="small muted">Read from the bus ${when(r.at)}.${canSend ? '' : ' Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}</p>`;
|
||||
+ }
|
||||
+ $('held-count').textContent = err && (refusal(err) || !r) ? '–' : String(r.rows.length);
|
||||
+ if (html === heldHtml) return; // unchanged: no repeated alert, focus stays put
|
||||
+ // Keep focus on the same control across the 10 s repaint.
|
||||
+ const a = document.activeElement, k = box.contains(a) ? { role: a.dataset?.messageRole, retry: a.hasAttribute?.('data-retry'), wrap: a.classList?.contains('table-wrap') } : null;
|
||||
+ box.innerHTML = html; heldHtml = html;
|
||||
+ if (k) {
|
||||
+ const el = k.role ? [...box.querySelectorAll('[data-message-role]')].find(b => b.dataset.messageRole === k.role) : k.retry ? box.querySelector('[data-retry]') : k.wrap ? box.querySelector('.table-wrap') : null;
|
||||
+ (el || $('main')).focus({ preventScroll: true });
|
||||
+ }
|
||||
+ }
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><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('');
|
||||
@@ -616,7 +656,7 @@
|
||||
}
|
||||
let gen = 0, shown = null, timer;
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
- function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
|
||||
+ function schedule() { clearTimeout(timer); if (!paused() && !out()) timer = setTimeout(() => render(false), 10000); }
|
||||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||||
// In the send form, the focused field and its caret too (its values come from `draft`).
|
||||
function keep() {
|
||||
@@ -640,6 +680,7 @@
|
||||
async function render(navigated) {
|
||||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||||
clearTimeout(timer);
|
||||
+ if (out()) return;
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||||
const g = ++gen;
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
@@ -648,9 +689,18 @@
|
||||
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 (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
||||
- if (g === gen) schedule();
|
||||
+ // The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||||
+ const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
|
||||
+ const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
|
||||
+ if (failed.some(err => err.code === 'signed-out')) return;
|
||||
+ if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
||||
+ if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
||||
+ const refused = failed.find(refusal);
|
||||
+ if (refused) forget();
|
||||
+ if (g !== gen) return;
|
||||
+ if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||||
+ paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
|
||||
+ sections(path); schedule();
|
||||
return;
|
||||
}
|
||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||||
@@ -658,6 +708,7 @@
|
||||
const live = reader(true);
|
||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
catch (err) {
|
||||
+ if (err.code === 'signed-out') return;
|
||||
const kept = reader(false);
|
||||
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
|
||||
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
|
||||
@@ -671,7 +722,10 @@
|
||||
const h1 = $('bus-view').querySelector('h1');
|
||||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||||
if (k) restore(k);
|
||||
+ // Message on the Board (row 95) lands in the form it opened, addressed to the role.
|
||||
+ else if (focusSend && $('send-body')) { $('send-body').focus(); announce(`Message to ${draft.to}`); }
|
||||
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||||
+ focusSend = false;
|
||||
schedule();
|
||||
}
|
||||
|
||||
@@ -697,7 +751,9 @@
|
||||
const item = e.target.closest('#cmdk-list [data-href]');
|
||||
if (item) { pkGo(item.dataset.href); return; }
|
||||
if (e.target.closest('#cmdk')) { pkOpen(); return; }
|
||||
- if (e.target.closest('#bus-view [data-retry]')) render(false);
|
||||
+ const message = e.target.closest('#held [data-message-role]');
|
||||
+ if (message) { draft.to = message.dataset.messageRole; focusSend = true; location.hash = '#/inbox'; return; }
|
||||
+ if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) render(false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- toast
|
||||
@@ -774,6 +830,7 @@
|
||||
e.preventDefault();
|
||||
if (sending) return;
|
||||
const to = draft.to, text = draft.body;
|
||||
+ if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||||
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||||
sending = true; receipt = null; paintSend();
|
||||
let res = null, body = null;
|
||||
@@ -795,9 +852,24 @@
|
||||
} 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');
|
||||
+ if (res?.type === 'opaqueredirect') signOut();
|
||||
+ });
|
||||
+ // Signed out: drop every bus read, kept ones included, and the page drawn from them. The send
|
||||
+ // form stays, with its draft and receipt, so nothing typed is lost; it sends nothing more.
|
||||
+ document.addEventListener('mosaic-signed-out', () => {
|
||||
+ clearTimeout(timer); gen++; forget();
|
||||
+ for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();
|
||||
+ $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;
|
||||
+ sections(routeOf().split('?')[0]); paintSend();
|
||||
});
|
||||
- // Whether this server takes a send here. The form is drawn on the next Inbox render.
|
||||
- fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(c => { if (c?.send === true) { canSend = true; if (routeOf().startsWith('/inbox')) render(false); } }).catch(() => {});
|
||||
+ // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
|
||||
+ // Message buttons are drawn from the roles already read.
|
||||
+ fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
|
||||
+ if (c?.send !== true) return;
|
||||
+ canSend = true;
|
||||
+ const path = routeOf().split('?')[0];
|
||||
+ if (path.startsWith('/inbox')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
+ }).catch(() => {});
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html
|
||||
index 7faa1883..adbcce42 100644
|
||||
--- a/packages/webui/src/public/index.html
|
||||
+++ b/packages/webui/src/public/index.html
|
||||
@@ -11,7 +11,8 @@
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">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="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>
|
||||
+<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><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 class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
+<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||||
<div id="error" class="banner err" role="alert" hidden></div>
|
||||
<div id="board-refused" class="empty" hidden><b>Nothing to show.</b><p>The board refused the read, so Console shows nothing rather than a guess. The reason is above.</p></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>
|
||||
diff --git a/packages/webui/src/public/shared/app.css b/packages/webui/src/public/shared/app.css
|
||||
index de94a98a..6a1a2f2a 100644
|
||||
--- a/packages/webui/src/public/shared/app.css
|
||||
+++ b/packages/webui/src/public/shared/app.css
|
||||
@@ -214,3 +214,10 @@ footer.foot{font-size:.8rem;color:var(--muted);padding:16px var(--pad);display:f
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||
@media (forced-colors:active){.badge,.btn,.chip,.panel,.wait-item,.task-card,.session-card{border:1px solid CanvasText}.badge::before{background:CanvasText}.nav-link[aria-current=page]{outline:2px solid Highlight}}
|
||||
@media print{#toasts,.skip,nav{display:none}.panel{box-shadow:none}}
|
||||
+
|
||||
+/* Console Board (row 95, #1589): the held roles lead the Board and leave with it; machine sessions
|
||||
+ show only under Show machine sessions; signed out, only the line and the send form stay. */
|
||||
+body.on-bus #held-section,body.has-conversation #held-section{display:none}
|
||||
+#held-section{margin-bottom:20px}
|
||||
+body.machine-off #board-view,body.machine-off #board-tree,body.machine-off #error,body.machine-off #board-refused{display:none}
|
||||
+body.signed-out #board-head,body.signed-out #held-section,body.signed-out #error,body.signed-out #board-refused,body.signed-out #conversation,body.signed-out #board-view,body.signed-out #inspector,body.signed-out #board-tree,body.signed-out #fresh,body.signed-out #bus-view>:not(#send-section){display:none}
|
||||
diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs
|
||||
index 27813054..446c9cc2 100644
|
||||
--- a/packages/webui/tests/browser-edge.test.mjs
|
||||
+++ b/packages/webui/tests/browser-edge.test.mjs
|
||||
@@ -22,6 +22,9 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
|
||||
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
|
||||
+ // Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
|
||||
+ assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
|
||||
+ await b.evaluate('document.querySelector("#show-machine").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
|
||||
await boardRequested;
|
||||
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
||||
diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs
|
||||
index 6aa7ad77..b6a19826 100644
|
||||
--- a/packages/webui/tests/browser.mjs
|
||||
+++ b/packages/webui/tests/browser.mjs
|
||||
@@ -4,7 +4,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
-export async function browser() {
|
||||
+// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
|
||||
+// so by default every page this browser opens starts with Show machine sessions on, as if the
|
||||
+// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
|
||||
+export async function browser({ machine = true } = {}) {
|
||||
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
|
||||
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
|
||||
'--headless', '--no-first-run', '--no-default-browser-check',
|
||||
@@ -39,6 +42,7 @@ export async function browser() {
|
||||
const call = (method, params) => send(method, params, sessionId);
|
||||
await call('Page.enable');
|
||||
await call('Runtime.enable');
|
||||
+ if (machine) await call('Page.addScriptToEvaluateOnNewDocument', { source: `try { localStorage.setItem('mosaic-console-board', '{"machine":true}'); } catch {}` });
|
||||
const evaluate = async expression => {
|
||||
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
|
||||
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
|
||||
index 675c813e..6007e0fc 100644
|
||||
--- a/packages/webui/tests/send-browser.test.mjs
|
||||
+++ b/packages/webui/tests/send-browser.test.mjs
|
||||
@@ -44,7 +44,7 @@ const STUB = `(() => {
|
||||
const c = await (await real(url, init)).json();
|
||||
return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
- if (u !== '/api/bus/message') return real(url, init);
|
||||
+ if (u !== '/api/bus/message') { window.__reads = (window.__reads || 0) + 1; return real(url, init); }
|
||||
window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
@@ -145,6 +145,28 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||||
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);
|
||||
+ // Row 95 (#1589): the redirect also signs the page out. One line says so; the form keeps its
|
||||
+ // draft and receipt; nothing read before stays; no further Console fetch is made, and a Send
|
||||
+ // is refused in the page.
|
||||
+ await wait('!document.querySelector("#signed-out").hidden');
|
||||
+ assert.equal(await text('#signed-out'), 'Signed out. Reload the page to sign in.');
|
||||
+ assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), true);
|
||||
+ assert.deepEqual(await b.evaluate('[...document.querySelector("#bus-view").children].map(e => e.id)'), ['send-section']);
|
||||
+ assert.doesNotMatch(await b.evaluate('document.body.innerText'), /NetworkError|Failed to fetch|did not answer/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled && document.querySelector("#pause").disabled'), true);
|
||||
+ const reads = await b.evaluate('window.__reads');
|
||||
+ await b.evaluate(`location.hash = '#/tasks', true`);
|
||||
+ await send();
|
||||
+ assert.equal(await receipt(), 'Not sent to coder: signed out; reload the page to sign in again (sign-in). The draft is kept.');
|
||||
+ await new Promise(r => setTimeout(r, 500));
|
||||
+ assert.equal(await b.evaluate('window.__reads'), reads);
|
||||
+ assert.equal((await sends()).length, 3);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
|
||||
+ // Reloading signs in again (here, nothing redirects) and the page works as before.
|
||||
+ await b.navigate(`${base}?again#/inbox`);
|
||||
+ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
+ await set('#send-to', 'coder'); await set('#send-body', msg);
|
||||
// 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);
|
||||
@@ -155,7 +177,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||||
}
|
||||
// Each Send was one POST; none was repeated after the unknown ones.
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
- assert.equal((await sends()).length, 7);
|
||||
+ assert.equal((await sends()).length, 4);
|
||||
|
||||
// While a send is pending, Send is disabled and a second press sends nothing; text typed
|
||||
// meanwhile is kept when the first one succeeds.
|
||||
@@ -165,18 +187,18 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||||
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, 8);
|
||||
+ assert.equal((await sends()).length, 5);
|
||||
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, 8);
|
||||
+ assert.equal((await sends()).length, 5);
|
||||
|
||||
// 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())[8].body).body, 'A second thought');
|
||||
+ assert.equal(JSON.parse((await sends())[5].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}`);
|
||||
@@ -189,7 +211,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||||
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, 9);
|
||||
+ assert.equal((await sends()).length, 6);
|
||||
} finally {
|
||||
await b.close(); await close(web); await f.close(); store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
|
||||
index fbea206c..2e372903 100644
|
||||
--- a/packages/webui/tests/shell.test.mjs
|
||||
+++ b/packages/webui/tests/shell.test.mjs
|
||||
@@ -364,7 +364,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
- await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
|
||||
+ // 1000px tall: since row 95 the Board leads with the held roles, so the short page is taller.
|
||||
+ await b.viewport(1440, 1000); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
|
||||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
|
||||
// The side column sits between the command bar and the status footer: a short page has no scroll,
|
||||
// and scrolled to the end of a long one the column is still below the bar.
|
||||
diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
|
||||
new file mode 100644
|
||||
index 00000000..19b875a8
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/board-held.test.mjs
|
||||
@@ -0,0 +1,209 @@
|
||||
+// Row 95 (#1589): the Board leads with the stack's held roles, from the bus `agents` read, and
|
||||
+// keeps the machine scan's sessions behind "Show machine sessions", off by default and kept in
|
||||
+// the page's settings. A redirect answer on any Console fetch is the sign-in proxy's: the page
|
||||
+// stops and says, in one line, to reload and sign in. The roles come from an in-process broker
|
||||
+// through its reader session. A script added before the page loads records every fetch the page
|
||||
+// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
|
||||
+// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
|
||||
+// under redirect: 'manual' (type opaqueredirect, status 0).
|
||||
+import { test } from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { mkdtempSync, 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 { browser } from './browser.mjs';
|
||||
+import { fixture, close } from './fixture.mjs';
|
||||
+
|
||||
+const businesses = {
|
||||
+ demo: {
|
||||
+ id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
+ roles: {
|
||||
+ pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ },
|
||||
+ },
|
||||
+};
|
||||
+
|
||||
+const STUB = `(() => {
|
||||
+ const real = window.fetch.bind(window);
|
||||
+ window.__reqs = []; window.__answers = []; window.__redirect = [];
|
||||
+ const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } });
|
||||
+ window.fetch = async (url, init = {}) => {
|
||||
+ const u = String(url), path = u.split('?')[0];
|
||||
+ window.__reqs.push({ path, redirect: init.redirect ?? null });
|
||||
+ if (window.__redirect.includes(path)) return redirect();
|
||||
+ if (path === '/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' } });
|
||||
+ }
|
||||
+ if (path !== '/api/bus/message') return real(url, init);
|
||||
+ const a = window.__answers.shift();
|
||||
+ if (!a) throw new TypeError('no answer queued');
|
||||
+ if (a.redirect) return redirect();
|
||||
+ return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
|
||||
+ };
|
||||
+})();`;
|
||||
+
|
||||
+async function setup(machine) {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'webui-board-held-'));
|
||||
+ const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
+ // pm's run has an address; coder's claim has none, which the Board still lists.
|
||||
+ b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||||
+ b0.request(b0.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'claude-code' }), { verb: 'role.claim' });
|
||||
+ const reader = b0.bindReader({ business: 'demo' });
|
||||
+ const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
+ const f = await fixture(), b = await browser({ machine });
|
||||
+ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
+ await b.viewport(1280);
|
||||
+ 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 shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||||
+ const reqs = () => b.evaluate('window.__reqs');
|
||||
+ const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
+ const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
+ return { b, base, wait, text, shown, reqs, settle, done };
|
||||
+}
|
||||
+
|
||||
+const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
|
||||
+const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||||
+
|
||||
+test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => {
|
||||
+ const { b, base, wait, text, shown, reqs, settle, done } = await setup(false);
|
||||
+ try {
|
||||
+ // Off by default: the held roles show, the machine scan is never read.
|
||||
+ await b.navigate(base);
|
||||
+ await wait(HELD);
|
||||
+ const rows = await b.evaluate('[...document.querySelectorAll("#held table tbody tr")].map(r => [...r.cells].map(c => c.textContent))');
|
||||
+ const byRole = Object.fromEntries(rows.map(r => [r[0], r]));
|
||||
+ assert.deepEqual(Object.keys(byRole).sort(), ['coder', 'pm']);
|
||||
+ assert.equal(byRole.coder[3], 'no address');
|
||||
+ assert.equal(byRole.pm[3], 'pm-run');
|
||||
+ assert.match(byRole.coder[4], /^coder-run/);
|
||||
+ assert.match(byRole.coder[1], /idle|waiting|working|unknown/);
|
||||
+ assert.equal(await text('#held-count'), '2');
|
||||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#held [data-message-role]")].map(e => e.getAttribute("aria-label")).sort()'), ['Message coder', 'Message pm']);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
|
||||
+ assert.match(await text('#status'), /^Machine sessions hidden\./);
|
||||
+ assert.equal(await shown('#board-view'), false);
|
||||
+ assert.equal(await shown('#board-tree'), false);
|
||||
+ await settle();
|
||||
+ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
|
||||
+ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
|
||||
+
|
||||
+ // On: the machine sessions are read and shown, and the choice is kept for the next load.
|
||||
+ await b.evaluate('document.querySelector("#show-machine").click(), true');
|
||||
+ await wait(SESSIONS);
|
||||
+ assert.equal(await shown('#board-view'), true);
|
||||
+ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}');
|
||||
+ await b.navigate(`${base}?on`);
|
||||
+ await wait(SESSIONS);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), true);
|
||||
+ await wait(HELD);
|
||||
+
|
||||
+ // Off again: the sessions go, the held roles stay, and the next load reads no board.
|
||||
+ await b.evaluate('document.querySelector("#show-machine").click(), true');
|
||||
+ await wait(`!${SESSIONS}`);
|
||||
+ assert.equal(await shown('#board-view'), false);
|
||||
+ assert.equal(await shown('#held-section'), true);
|
||||
+ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":false}');
|
||||
+ await b.navigate(`${base}?off`);
|
||||
+ await wait(HELD);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
|
||||
+ await settle();
|
||||
+ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
|
||||
+
|
||||
+ // Message opens the send form addressed to that role, with the caret in the message.
|
||||
+ for (const role of ['coder', 'pm']) {
|
||||
+ await b.evaluate(`location.hash = '#/', true`);
|
||||
+ await wait(HELD);
|
||||
+ await b.evaluate(`document.querySelector('#held [data-message-role="${role}"]').click(), true`);
|
||||
+ await wait(`location.hash === '#/inbox' && document.querySelector('#send-to')?.value === '${role}' && document.activeElement === document.querySelector('#send-body')`);
|
||||
+ assert.equal(await shown('#held-section'), false);
|
||||
+ }
|
||||
+
|
||||
+ // Without send there is no Message action; the roles still show.
|
||||
+ await b.navigate(`${base}?nosend`);
|
||||
+ await wait(HELD);
|
||||
+ assert.equal(await b.evaluate('document.querySelectorAll("#held [data-message-role]").length'), 0);
|
||||
+ assert.match(await text('#held'), /--send/);
|
||||
+ // Every Console fetch the page made asked for redirect: 'manual'.
|
||||
+ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
|
||||
+ } finally { await done(); }
|
||||
+});
|
||||
+
|
||||
+test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
|
||||
+ const { b, base, wait, text, shown, reqs, settle, done } = await setup(true);
|
||||
+ const LINE = 'Signed out. Reload the page to sign in.';
|
||||
+ const out = async () => {
|
||||
+ await wait('!document.querySelector("#signed-out").hidden');
|
||||
+ assert.equal(await text('#signed-out'), LINE);
|
||||
+ assert.equal(await b.evaluate('document.activeElement === document.querySelector("#signed-out") || document.activeElement?.closest?.("#send-section") !== null'), true);
|
||||
+ const page = await b.evaluate('document.body.innerText');
|
||||
+ // No failure text from the redirected read either: the footer stays visible when signed out.
|
||||
+ assert.doesNotMatch(page, /NetworkError|Failed to fetch|did not answer|Board unreachable|[Nn]ot read|last known data/);
|
||||
+ assert.equal(page.split(LINE).length, 2);
|
||||
+ // Nothing read before stays on the page as if current.
|
||||
+ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0);
|
||||
+ for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||||
+ // No further Console fetch: not on a route change, a Refresh, the toggle or the poll.
|
||||
+ const n = (await reqs()).length;
|
||||
+ await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`);
|
||||
+ await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true');
|
||||
+ await settle();
|
||||
+ assert.equal((await reqs()).length, n);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
|
||||
+ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
|
||||
+ };
|
||||
+ try {
|
||||
+ // A Board read: the machine scan's /api/board, after a good first read.
|
||||
+ await b.navigate(base);
|
||||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||||
+ await b.evaluate(`window.__redirect.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||||
+ await out();
|
||||
+
|
||||
+ // A bus read: the held roles' agents read, on a later Refresh.
|
||||
+ await b.navigate(`${base}?bus`);
|
||||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
+ await b.evaluate(`window.__redirect.push('/api/bus/agents'), document.querySelector('#refresh').click(), true`);
|
||||
+ await out();
|
||||
+
|
||||
+ // A conversation read: History on a session asks for the catalogue.
|
||||
+ await b.navigate(`${base}?conv`);
|
||||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||||
+ await b.evaluate(`window.__redirect.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||||
+ await out();
|
||||
+
|
||||
+ // A board POST: Seen in the inspector posts /api/seen.
|
||||
+ await b.navigate(`${base}?seen`);
|
||||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||||
+ await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
+ await wait('document.querySelector("#inspection [data-seen]")');
|
||||
+ await b.evaluate(`window.__redirect.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
+ await out();
|
||||
+
|
||||
+ // A module read: the Queue page's /api/queue.
|
||||
+ await b.navigate(`${base}?queue`);
|
||||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||||
+ await b.evaluate(`window.__redirect.push('/api/queue'), location.hash = '#/queue', true`);
|
||||
+ await out();
|
||||
+
|
||||
+ // A send: the receipt says it was not sent and keeps the draft; the form stays for the draft.
|
||||
+ await b.navigate(`${base}?send#/inbox`);
|
||||
+ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
|
||||
+ await b.evaluate(`(()=>{const t=document.querySelector('#send-to'),m=document.querySelector('#send-body');t.value='coder';t.dispatchEvent(new Event('input',{bubbles:true}));m.value='Kept draft';m.dispatchEvent(new Event('input',{bubbles:true}));window.__answers.push({redirect:true});document.querySelector('#send-form [data-send]').click();return true})()`);
|
||||
+ await wait('document.querySelector("#send-section .receipt")');
|
||||
+ assert.match(await text('#send-section .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 b.evaluate('document.querySelector("#send-body").value'), 'Kept draft');
|
||||
+ assert.equal(await shown('#send-section'), true);
|
||||
+ await out();
|
||||
+ } finally { await done(); }
|
||||
+});
|
||||
@@ -0,0 +1,10 @@
|
||||
a21e4e5b274146bbc0159e7673fc303c9c37fc8757f28f38ea30f28a2eea79c7 packages/webui/README.md
|
||||
1e017d4d4c0e1326e5d4368d7215a950e89bfd7a74126208d04a0eba15c2e65f packages/webui/src/public/app.js
|
||||
59a14ac07170b2e269f966c07779c7565a9bccc253be6b68cc90a6711f87caf0 packages/webui/src/public/bus.js
|
||||
34180d9c53a25beb3fff590bf37b6b0def72103542934355de87c05b18d2bfed packages/webui/src/public/index.html
|
||||
dbffe11bbdd4226ffabb2a8bee534258a23e766259489edb4eaf929e5c1b899a packages/webui/src/public/shared/app.css
|
||||
d34ea09b02327298532da3442791035d73cc055be3927324799989d9a89ad125 packages/webui/tests/browser-edge.test.mjs
|
||||
1e70a0175f1d660f6b71449a1423ece83fbe8332108960fa9aff6f7a5e48022a packages/webui/tests/browser.mjs
|
||||
68db3ef7101267d9c5d96c1a69ce4c078bd3563c4251f5d82b1ba38c62a8b533 packages/webui/tests/send-browser.test.mjs
|
||||
5e94f3d5ca6bcbbd43926be61a7195b270687b0fdd7b054f736cfba320e5bdc1 packages/webui/tests/shell.test.mjs
|
||||
9d3bb4c9436685ad69b834c0b6849395664320b28f32fed3f31ddfbfd815253f packages/webui/tests/board-held.test.mjs
|
||||
@@ -0,0 +1,132 @@
|
||||
✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (4.980463ms)
|
||||
✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.272275ms)
|
||||
✔ completed smoke replies and ordinary questions are idle, not human blockers (1.303711ms)
|
||||
✔ only an explicit first-line input request makes a finished reply waiting (0.281027ms)
|
||||
✔ tool activity, user text, errors and unfinished turns override attention text (0.217434ms)
|
||||
✔ STOP access failure is unknown, not absence, under a non-root identity (44.907294ms)
|
||||
✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (3.708872ms)
|
||||
✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (1.751708ms)
|
||||
✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (8.083017ms)
|
||||
✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (0.7868ms)
|
||||
✔ server rescans connector discovery and refuses HTTP reply without transport (46.884682ms)
|
||||
✔ connector session links and linked directories are not read (1.295115ms)
|
||||
✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (2.722154ms)
|
||||
✔ CLI print uses the relaunch notice instead of old current preview (104.326813ms)
|
||||
✔ connector owner and fixed task never inherit a native relaunch notice (2.584048ms)
|
||||
✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (2.689431ms)
|
||||
✔ loadConfig: missing file throws ConfigError (1.990891ms)
|
||||
✔ loadConfig: invalid JSON throws ConfigError (0.3859ms)
|
||||
✔ loadConfig: missing dataRoot throws ConfigError (0.282817ms)
|
||||
✔ loadConfig: relative dataRoot throws ConfigError (0.245402ms)
|
||||
✔ loadConfig: valid config returns dataRoot (0.759982ms)
|
||||
✔ findNewestSession: picks the newest by mtime among two files (0.518653ms)
|
||||
✔ findNewestSession: finds files in nested subdirectories (0.343092ms)
|
||||
✔ findNewestSession: returns null for a missing dir (0.141133ms)
|
||||
✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (0.960094ms)
|
||||
✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (1.573448ms)
|
||||
✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (0.717431ms)
|
||||
✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (1.203237ms)
|
||||
✔ deriveState: full state table (0.193974ms)
|
||||
✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.371336ms)
|
||||
✔ rule: newest entry is a tool result with no assistant text after it is working (0.297966ms)
|
||||
✔ rule: a finished ordinary turn is idle, even if it says your move (0.2992ms)
|
||||
✔ task: the first user message of the session, from text blocks (0.295735ms)
|
||||
✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.287859ms)
|
||||
✔ task: no user message in the log means null (shown as unknown), never a guess (0.300942ms)
|
||||
✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.448892ms)
|
||||
✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (0.649005ms)
|
||||
✔ scan: the written record carries task, workspace and activeProject (0.647152ms)
|
||||
✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (0.675469ms)
|
||||
✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.421012ms)
|
||||
✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (1.049774ms)
|
||||
✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.463045ms)
|
||||
✔ loadRegistrations: a missing seatsDir gives empty lists (0.171517ms)
|
||||
✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (0.827255ms)
|
||||
✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (0.355393ms)
|
||||
✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (0.905133ms)
|
||||
✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.84953ms)
|
||||
✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.656805ms)
|
||||
✔ scanAgent: ageSeconds is computed from the injected now (0.282529ms)
|
||||
✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.158459ms)
|
||||
✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.354265ms)
|
||||
✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.373748ms)
|
||||
✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.055963ms)
|
||||
✔ scan: relative boardDir throws ConfigError (0.091352ms)
|
||||
✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (65.443737ms)
|
||||
✔ CLI: missing config exits 2 with a refused: message (66.984113ms)
|
||||
✔ CLI: unknown command exits 2 (59.279253ms)
|
||||
✔ CLI: unknown --liveness value exits 2 (56.675922ms)
|
||||
✔ panesRunPi: true when any trimmed line equals 'pi' (0.299223ms)
|
||||
✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.122281ms)
|
||||
✔ tmuxIsAlive: a pane running pi is alive (0.24044ms)
|
||||
✔ tmuxIsAlive: session exists but pi has exited is not alive (0.083883ms)
|
||||
✔ tmuxIsAlive: no such tmux session is not alive (0.069633ms)
|
||||
✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.104304ms)
|
||||
✔ tmuxIsAlive: passes -L <socket> only when a socket is given (0.107395ms)
|
||||
✔ parsePanes: one pane per line, command and optional tab-separated path (0.082886ms)
|
||||
✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.07376ms)
|
||||
✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.1003ms)
|
||||
✔ loadSeen: missing file returns {} (0.175416ms)
|
||||
✔ loadSeen: invalid JSON throws ConfigError (0.224088ms)
|
||||
✔ loadSeen: a JSON array throws ConfigError (0.165393ms)
|
||||
✔ loadSeen: a non-string value throws ConfigError (0.205174ms)
|
||||
✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.384651ms)
|
||||
✔ markSeen: seen false deletes the key (0.347974ms)
|
||||
✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.253717ms)
|
||||
✔ markSeen: project containing '/' throws ConfigError (0.146079ms)
|
||||
✔ markSeen: non-boolean seen throws ConfigError (0.115722ms)
|
||||
✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.340147ms)
|
||||
✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.261968ms)
|
||||
✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.256389ms)
|
||||
✔ scanAgent: an error-state session with a matching mark is seen (0.258928ms)
|
||||
✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.673884ms)
|
||||
✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.284213ms)
|
||||
✔ taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered (0.773582ms)
|
||||
✔ taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present (0.689493ms)
|
||||
✔ taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value (1.221496ms)
|
||||
✔ isLoopbackHost: recognizes loopback hosts (1.453906ms)
|
||||
✔ isLoopbackHost: rejects non-loopback hosts (4.703991ms)
|
||||
✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (3.375558ms)
|
||||
✔ startServer: serves page, healthz, and a rescanning /api/board (39.525794ms)
|
||||
✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (7.031069ms)
|
||||
✔ startServer: /api/board returns 500 JSON with an error field when scan throws (3.290601ms)
|
||||
✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (70.342411ms)
|
||||
✔ CLI: serve rejects a non-numeric --port with exit 2 (62.465037ms)
|
||||
✔ CLI: scan still works after the async cli refactor (62.705613ms)
|
||||
✔ CLI: live serve prints its URL and answers /healthz (70.544074ms)
|
||||
✔ page.html: esc() escapes every HTML-significant character (0.728332ms)
|
||||
✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (9.412186ms)
|
||||
✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (2.383613ms)
|
||||
✔ POST /api/seen with invalid JSON returns 400 (3.31709ms)
|
||||
✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (2.384349ms)
|
||||
✔ POST /api/seen with a missing agent returns 400 (1.487727ms)
|
||||
✔ POST /api/board returns 405; PUT /api/seen returns 405 (1.886858ms)
|
||||
✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (59.53651ms)
|
||||
✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.441732ms)
|
||||
✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.339208ms)
|
||||
✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.225411ms)
|
||||
✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.169323ms)
|
||||
✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.390173ms)
|
||||
✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (0.743974ms)
|
||||
✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (36.497378ms)
|
||||
✔ POST /api/reply: a registration with a tmux socket adds -L <socket> (34.056964ms)
|
||||
✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (32.762631ms)
|
||||
✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (18.671123ms)
|
||||
✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (5.812585ms)
|
||||
✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (1.563312ms)
|
||||
✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.677122ms)
|
||||
✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (3.593228ms)
|
||||
✔ page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it (0.399106ms)
|
||||
✔ Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers (9.621515ms)
|
||||
✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (40.151026ms)
|
||||
✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (6.822974ms)
|
||||
✔ every refusal code the reader can raise has an HTTP status (3.177074ms)
|
||||
✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (56.998429ms)
|
||||
ℹ tests 124
|
||||
ℹ suites 0
|
||||
ℹ pass 124
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 777.678976
|
||||
@@ -0,0 +1,13 @@
|
||||
start 2026-10-12T21:27:35Z
|
||||
webui rc=0 ℹ pass 86 ℹ fail 0
|
||||
control-board rc=0 ℹ pass 124 ℹ fail 0
|
||||
test-auth rc=0 selftest: 15 passed, 0 failed
|
||||
test-conductor rc=0 selftest: 17 passed, 0 failed
|
||||
test-config rc=0 selftest: 24 passed, 0 failed
|
||||
test-discord rc=0 discord suite: 66 passed, 0 failed
|
||||
test-extension-package rc=0 extension package selftest: 18 passed, 0 failed
|
||||
test-foundation rc=0 selftest: 44 passed, 0 failed
|
||||
test-queue rc=0 queue suite: 27 passed, 0 failed
|
||||
test-release rc=0 selftest: 4 passed, 0 failed
|
||||
test-task rc=0 selftest: 26 passed, 0 failed
|
||||
end 2026-10-12T21:30:19Z
|
||||
@@ -0,0 +1,17 @@
|
||||
OK status with missing harness credential exits 3 and still lists accounts
|
||||
OK status reports harness credential (read-only) + mosaic accounts
|
||||
OK api key material never reaches output
|
||||
OK oauth token material never reaches output
|
||||
OK unparseable credential file exits 2
|
||||
OK symlinked credential file exits 4
|
||||
OK env-side credential names reported
|
||||
OK env var values never reach output
|
||||
OK accounts without an accounts dir reports none and creates nothing
|
||||
OK accounts lists files and marks the active one
|
||||
OK loose account perms flagged in listing
|
||||
OK agent --auth with missing account file refuses (exit 4)
|
||||
OK agent --auth with non-0600 account file refuses
|
||||
OK agent --auth with invalid account name refuses
|
||||
OK auth.sh without valid config refuses
|
||||
|
||||
selftest: 15 passed, 0 failed
|
||||
@@ -0,0 +1,55 @@
|
||||
Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
|
||||
|
||||
You are in 'detached HEAD' state. You can look around, make experimental
|
||||
changes and commit them, and you can discard any commits you make in this
|
||||
state without impacting any branches by switching back to a branch.
|
||||
|
||||
If you want to create a new branch to retain commits you create, you may
|
||||
do so (now or later) by using -c with the switch command. Example:
|
||||
|
||||
git switch -c <new-branch-name>
|
||||
|
||||
Or undo this operation with:
|
||||
|
||||
git switch -
|
||||
|
||||
Turn off this advice by setting config variable advice.detachedHead to false
|
||||
|
||||
Not currently on any branch.
|
||||
nothing to commit, working tree clean
|
||||
Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
|
||||
|
||||
You are in 'detached HEAD' state. You can look around, make experimental
|
||||
changes and commit them, and you can discard any commits you make in this
|
||||
state without impacting any branches by switching back to a branch.
|
||||
|
||||
If you want to create a new branch to retain commits you create, you may
|
||||
do so (now or later) by using -c with the switch command. Example:
|
||||
|
||||
git switch -c <new-branch-name>
|
||||
|
||||
Or undo this operation with:
|
||||
|
||||
git switch -
|
||||
|
||||
Turn off this advice by setting config variable advice.detachedHead to false
|
||||
|
||||
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
|
||||
OK dry-run committed nothing
|
||||
OK apply: allowed change exits 0 (exit 0)
|
||||
OK apply: attribution in commit subject
|
||||
OK apply: target tree clean after commit
|
||||
OK disallowed path refused (exit 1)
|
||||
OK disallowed path: target untouched
|
||||
OK syntax gate refused broken .mjs (exit 1)
|
||||
OK syntax gate: target untouched
|
||||
OK suite failure refused (exit 1)
|
||||
OK suite failure: target reverted to clean
|
||||
OK disabled policy refused (exit 2)
|
||||
OK disabled policy: target untouched
|
||||
OK failed run refused (exit 1)
|
||||
OK failed run: target untouched
|
||||
OK missing run exits 4 (exit 4)
|
||||
OK invalid policy exits 2 (exit 2)
|
||||
|
||||
selftest: 17 passed, 0 failed
|
||||
@@ -0,0 +1,26 @@
|
||||
OK absent adapter defaults to pi
|
||||
OK adapter mock validates (exit 0)
|
||||
OK unsupported adapter exits 2 (exit 2)
|
||||
OK env exports adapter
|
||||
OK bootstrap creates default when absent (exit 0)
|
||||
OK bootstrap wrote config file
|
||||
OK bootstrap is idempotent on existing config (exit 0)
|
||||
OK bootstrap did not rewrite existing config
|
||||
OK validate missing config exits 3 (exit 3)
|
||||
OK malformed JSON exits 2 (exit 2)
|
||||
OK unsupported configVersion exits 2 (exit 2)
|
||||
OK unknown top-level key exits 2 (exit 2)
|
||||
OK unknown execution key exits 2 (exit 2)
|
||||
OK unsupported backend exits 2 (exit 2)
|
||||
OK unsupported environment exits 2 (exit 2)
|
||||
OK relative dataRoot exits 2 (exit 2)
|
||||
OK non-canonical dataRoot exits 2 (exit 2)
|
||||
OK filesystem root dataRoot exits 2 (exit 2)
|
||||
OK home directory dataRoot exits 2 (exit 2)
|
||||
OK dataRoot containing config dir exits 2 (exit 2)
|
||||
OK control character in provider exits 2 (exit 2)
|
||||
OK symlinked config file exits 2 (exit 2)
|
||||
OK env exports resolve correctly
|
||||
OK failed validation modified nothing
|
||||
|
||||
selftest: 24 passed, 0 failed
|
||||
@@ -0,0 +1,70 @@
|
||||
toolchain: node v26.8.1
|
||||
|
||||
OK syntax: packages/discord/src/approvals.mjs
|
||||
OK syntax: packages/discord/src/authorize.mjs
|
||||
OK syntax: packages/discord/src/binding.mjs
|
||||
OK syntax: packages/discord/src/cli.mjs
|
||||
OK syntax: packages/discord/src/connector.mjs
|
||||
OK syntax: packages/discord/src/context.mjs
|
||||
OK syntax: packages/discord/src/engine-pi.mjs
|
||||
OK syntax: packages/discord/src/errors.mjs
|
||||
OK syntax: packages/discord/src/gateway.mjs
|
||||
OK syntax: packages/discord/src/git.mjs
|
||||
OK syntax: packages/discord/src/journal.mjs
|
||||
OK syntax: packages/discord/src/notify.mjs
|
||||
OK syntax: packages/discord/src/rest.mjs
|
||||
OK syntax: packages/discord/src/setspark.mjs
|
||||
OK syntax: packages/discord/src/tools.mjs
|
||||
OK syntax: packages/discord/src/web.mjs
|
||||
OK syntax: packages/discord/bin/git-credential.mjs
|
||||
OK syntax: packages/discord/extension/tools.mjs
|
||||
OK syntax: packages/discord/tests/approvals.test.mjs
|
||||
OK syntax: packages/discord/tests/authorize.test.mjs
|
||||
OK syntax: packages/discord/tests/binding.test.mjs
|
||||
OK syntax: packages/discord/tests/connector.test.mjs
|
||||
OK syntax: packages/discord/tests/context.test.mjs
|
||||
OK syntax: packages/discord/tests/engine.test.mjs
|
||||
OK syntax: packages/discord/tests/fake-pi.mjs
|
||||
OK syntax: packages/discord/tests/gateway.test.mjs
|
||||
OK syntax: packages/discord/tests/git.test.mjs
|
||||
OK syntax: packages/discord/tests/helpers.mjs
|
||||
OK syntax: packages/discord/tests/journal.test.mjs
|
||||
OK syntax: packages/discord/tests/notify.test.mjs
|
||||
OK syntax: packages/discord/tests/recover.test.mjs
|
||||
OK syntax: packages/discord/tests/rest.test.mjs
|
||||
OK syntax: packages/discord/tests/setspark.test.mjs
|
||||
OK syntax: packages/discord/tests/tools.test.mjs
|
||||
OK syntax: packages/discord/tests/web.test.mjs
|
||||
OK syntax: packages/discord/fixtures/claim-worker.mjs
|
||||
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
|
||||
OK syntax: scripts/discord.sh
|
||||
OK syntax: scripts/discord-service.sh
|
||||
OK packages/discord declares no dependencies
|
||||
OK no bot-token-shaped string in packages/discord
|
||||
OK fixture binding uses placeholder ids only
|
||||
OK fixture binding validates
|
||||
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
|
||||
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
|
||||
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
|
||||
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
|
||||
OK real pi with protocol vault adds reserve_id to the git verbs
|
||||
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
|
||||
OK real pi refuses a git key on a read-only root (fail closed)
|
||||
OK real pi with the pilot flags (--no-tools) exposes no tool at all
|
||||
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test packages/discord/tests/ (ℹ pass 178)
|
||||
OK scripts/discord.sh --help exits 0
|
||||
OK scripts/discord.sh check without a binding exits 4
|
||||
OK scripts/discord.sh recover without a binding exits 4
|
||||
OK scripts/discord.sh reload without a binding exits 4
|
||||
OK scripts/discord-service.sh without a command exits 4
|
||||
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
|
||||
OK service install writes the rendered unit (0644) and leaves no temp file
|
||||
OK service install a second time reports unchanged
|
||||
OK systemd-analyze verify accepts the rendered unit
|
||||
OK service uninstall removes the unit file
|
||||
OK service install with an unknown flag exits 4
|
||||
OK service install with USER unset finishes and names the account for lingering
|
||||
|
||||
discord suite: 66 passed, 0 failed
|
||||
@@ -0,0 +1,21 @@
|
||||
OK initial ordinary-file install
|
||||
OK installed tree matches canonical source
|
||||
OK installed tree has no symlinks
|
||||
OK check detects installation drift
|
||||
OK sync refuses to overwrite installation drift
|
||||
OK check detects an extra destination file
|
||||
OK check detects an extra destination directory
|
||||
OK check rejects a destination symlink
|
||||
OK sync accepts a canonical source update
|
||||
OK updated installation matches canonical source
|
||||
scripts/test-extension-package.sh: line 14: 2381477 Killed "$@" > /dev/null 2>&1
|
||||
OK forced interruption kills the replacing process
|
||||
OK next invocation recovers old consistent installation
|
||||
OK interrupted replacement rolled back
|
||||
OK sync succeeds after interruption recovery
|
||||
OK unlocked stale lock file does not block
|
||||
OK active lock refuses a concurrent sync
|
||||
OK source symlink fails closed
|
||||
OK nested second entrypoint fails closed
|
||||
|
||||
extension package selftest: 18 passed, 0 failed
|
||||
@@ -0,0 +1,53 @@
|
||||
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
|
||||
|
||||
OK syntax: scripts/foundation-inspect.mjs
|
||||
OK syntax: scripts/foundation/strict-json.mjs
|
||||
OK syntax: scripts/foundation/canonical.mjs
|
||||
OK syntax: scripts/foundation/resolve.mjs
|
||||
OK syntax: scripts/foundation/validate-record.mjs
|
||||
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
|
||||
OK syntax: scripts/foundation/canonical.test.mjs
|
||||
OK syntax: scripts/foundation/cli.test.mjs
|
||||
OK syntax: scripts/foundation/fixtures.test.mjs
|
||||
OK syntax: scripts/foundation/resolve.test.mjs
|
||||
OK syntax: scripts/foundation/strict-json.test.mjs
|
||||
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
|
||||
OK fixture generator runs
|
||||
OK checked-in fixtures/bundles equal a fresh generation
|
||||
OK checked-in fixtures/raw equal a fresh generation
|
||||
OK checked-in fixtures/index.json equal a fresh generation
|
||||
OK checked-in demo bundles equal a fresh generation
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test scripts/foundation/ (ℹ pass 80)
|
||||
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
|
||||
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
|
||||
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
|
||||
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
|
||||
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
|
||||
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
|
||||
OK oracle: zero schema-column disagreements with the pinned checker
|
||||
OK oracle: strict-only profile refusals are counted and asserted
|
||||
OK demo: permitted read preview exits 0 (exit 0)
|
||||
OK demo: permitted file.change preview exits 0 (exit 0)
|
||||
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
|
||||
OK demo: revoked registration is refused (exit 3) (exit 3)
|
||||
OK demo: message is not authority (exit 3) (exit 3)
|
||||
OK usage: no arguments exits 2 (exit 2)
|
||||
OK io: missing file exits 4 (exit 4)
|
||||
OK io: directory exits 4 (exit 4)
|
||||
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
|
||||
OK bound: oversize fixture exits 2 (exit 2)
|
||||
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
|
||||
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
|
||||
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
|
||||
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
|
||||
OK text output starts with the disclaimer
|
||||
OK json output is valid JSON with result allowed and exactly the charter §7 fields
|
||||
OK json golden matches byte-for-byte
|
||||
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
|
||||
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
|
||||
OK canary never printed (bundle run and credential-file run)
|
||||
OK a non-bundle JSON file is refused at the shape gate, not read into output
|
||||
OK no field of the non-bundle file is echoed
|
||||
|
||||
selftest: 44 passed, 0 failed
|
||||
@@ -0,0 +1,35 @@
|
||||
toolchain: node v26.8.1, git version 2.55.0
|
||||
|
||||
OK syntax: packages/queue/src/cli.mjs
|
||||
OK syntax: packages/queue/src/errors.mjs
|
||||
OK syntax: packages/queue/src/io.mjs
|
||||
OK syntax: packages/queue/src/lock.mjs
|
||||
OK syntax: packages/queue/src/queue.mjs
|
||||
OK syntax: packages/queue/src/review.mjs
|
||||
OK syntax: packages/queue/src/store.mjs
|
||||
OK syntax: packages/queue/tests/commit.test.mjs
|
||||
OK syntax: packages/queue/tests/data.test.mjs
|
||||
OK syntax: packages/queue/tests/dispatch.test.mjs
|
||||
OK syntax: packages/queue/tests/helpers.mjs
|
||||
OK syntax: packages/queue/tests/lock.test.mjs
|
||||
OK syntax: packages/queue/tests/migration.test.mjs
|
||||
OK syntax: packages/queue/tests/review.test.mjs
|
||||
OK syntax: packages/queue/tests/store.test.mjs
|
||||
OK syntax: packages/queue/tests/write.test.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
|
||||
OK syntax: scripts/queue-commit.sh
|
||||
OK syntax: scripts/git-hooks/pre-commit
|
||||
OK syntax: scripts/mosaic
|
||||
OK queue-commit.sh, the guard and scripts/mosaic are executable
|
||||
OK packages/queue declares no dependencies
|
||||
ℹ tests 150
|
||||
ℹ pass 150
|
||||
ℹ fail 0
|
||||
OK node --test packages/queue/tests/
|
||||
OK scripts/mosaic queue help
|
||||
skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r95w) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
|
||||
|
||||
queue suite: 27 passed, 0 failed
|
||||
@@ -0,0 +1,7 @@
|
||||
OK valid RELEASE resolves (exit 0)
|
||||
OK invalid RELEASE exits 1 (exit 1)
|
||||
OK missing RELEASE exits 1 (exit 1)
|
||||
OK valid RELEASE leaves image tag consistent with version
|
||||
skip state-machine cases (docker daemon unavailable)
|
||||
|
||||
selftest: 4 passed, 0 failed
|
||||
@@ -0,0 +1,32 @@
|
||||
OK valid task validates (exit 0)
|
||||
OK unknown task key exits 2 (exit 2)
|
||||
OK unsupported taskVersion exits 2 (exit 2)
|
||||
OK invalid task id exits 2 (exit 2)
|
||||
OK empty prompt exits 2 (exit 2)
|
||||
OK NUL in expectExact exits 2 (exit 2)
|
||||
OK out-of-range timeout exits 2 (exit 2)
|
||||
OK missing mission file exits 4 (exit 4)
|
||||
OK task with valid mission validates (exit 0)
|
||||
OK invalid mission exits 2 (exit 2)
|
||||
OK validate missing task exits 4 (exit 4)
|
||||
OK validation does not modify the task file
|
||||
OK prune dry-run exits 0 (exit 0)
|
||||
OK dry-run deleted nothing
|
||||
OK prune --keep=2 --yes removes oldest (exit 0)
|
||||
OK kept exactly 2 newest runs
|
||||
OK newest run kept, oldest pruned
|
||||
OK append-only receipt written (3 entries)
|
||||
OK sessions/workspaces untouched by prune
|
||||
OK prune with invalid keep exits 4 (exit 4)
|
||||
skip adapter seam cases (docker daemon unavailable)
|
||||
skip workspace/capability cases (docker daemon unavailable)
|
||||
skip live task cases (docker unavailable)
|
||||
OK onboard without name exits 4 (non-interactive) (exit 4)
|
||||
OK onboard --name renders profile (exit 0)
|
||||
OK profile written
|
||||
OK canon structure: required filled, optional placeholdered
|
||||
OK canon sections present
|
||||
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
|
||||
OK no agent identity on headless run
|
||||
|
||||
selftest: 26 passed, 0 failed
|
||||
@@ -0,0 +1,95 @@
|
||||
✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (7492.290142ms)
|
||||
✔ a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads (7443.490999ms)
|
||||
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (5015.364507ms)
|
||||
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
|
||||
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (7076.42401ms)
|
||||
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (10731.026078ms)
|
||||
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (602.765774ms)
|
||||
✔ decisions route serves the roles and recent lists from the reader view (1815.613031ms)
|
||||
✔ credentials route serves the broker credential status from the reader view (826.587477ms)
|
||||
✔ replies route serves the replies to one message from the reader view (1202.275155ms)
|
||||
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (276.321263ms)
|
||||
✔ bus refusals map to statuses and never carry a path or a stack (89.179741ms)
|
||||
✔ humanCall speaks the human transport protocol without blocking the server (2475.650074ms)
|
||||
✔ busReader takes --business, else the live bus host, else refuses (482.043377ms)
|
||||
✔ humanCall kills a transport that runs past its timeout (534.640365ms)
|
||||
✔ serve refuses a --business value that is not a business id (525.978048ms)
|
||||
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (4701.592645ms)
|
||||
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5497.243708ms)
|
||||
✔ conversation view: a newer session with no readable history keeps the marker (2501.036152ms)
|
||||
✔ conversation view: seats without history say so and offer no reply (2209.329635ms)
|
||||
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (4833.374502ms)
|
||||
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (57170.098808ms)
|
||||
✔ preview options are all three or none, and each is checked (16.770379ms)
|
||||
✔ the CLI refuses a partial or invalid preview flag set at start (4458.074961ms)
|
||||
✔ the CLI with all three flags serves the preview mode (623.146434ms)
|
||||
✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (187.420244ms)
|
||||
✔ the header name matches without regard to case, and the value compare is exact (39.834406ms)
|
||||
✔ the public name with the header reads the bus views, and the Host and Origin are checked first (146.238762ms)
|
||||
✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (80.412946ms)
|
||||
✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (19.369922ms)
|
||||
✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (88.442643ms)
|
||||
✔ a repeated Host is 400 before any other check, in preview mode and without it (54.92512ms)
|
||||
✔ without the preview flags the public name is refused and writes still reach the board (48.983846ms)
|
||||
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (3.60975ms)
|
||||
✔ credential state from the date alone, as the bus states it (0.383673ms)
|
||||
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3773.425083ms)
|
||||
✔ queue: a refused read fails closed with the store's text, redacted (1904.730011ms)
|
||||
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (4442.987441ms)
|
||||
✔ business: names, vars on the allowlist, authority, credential metadata only (19.589813ms)
|
||||
✔ business: missing or invalid file refuses with the module's text, redacted (39.754666ms)
|
||||
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (31.70911ms)
|
||||
✔ settings: release, board, loopback address, notifier binding name only (782.580405ms)
|
||||
✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (42.11733ms)
|
||||
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (4992.292654ms)
|
||||
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (23676.580187ms)
|
||||
✔ 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 (10410.664684ms)
|
||||
✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (13022.330805ms)
|
||||
✔ startServer takes send only as a function and only with the preview mode (10.766006ms)
|
||||
✔ the CLI refuses --send without the preview flags, and without a configured bus (3757.284618ms)
|
||||
✔ the CLI with --send and a configured bus sends through the human transport and logs one line (985.404826ms)
|
||||
✔ without --send the route is read-only like every other write, and config says no send (39.754347ms)
|
||||
✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (38.596075ms)
|
||||
✔ config says send only on the public name with --send on (15.970811ms)
|
||||
✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (27.630231ms)
|
||||
✔ each request check refuses before anything is sent, and logs the refusal without the body (96.413478ms)
|
||||
✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (99.145416ms)
|
||||
✔ a crafted role name cannot forge a log line (15.768661ms)
|
||||
✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (62.896628ms)
|
||||
✔ through the human transport the request is exactly the business, message.send and the keys (484.518564ms)
|
||||
✔ against a real broker: the message is written once as a human send, and its refusals map (1809.931442ms)
|
||||
✔ loopback host and board origin fail closed (10.61809ms)
|
||||
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (224.574779ms)
|
||||
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (626.992929ms)
|
||||
✔ unreachable board reports URL; CLI rejects unsupported options (2758.214386ms)
|
||||
✔ render prints the unit without the preview flags (83.686445ms)
|
||||
✔ render carries every serve flag, the preview set included (74.488686ms)
|
||||
✔ a partial preview set is refused (152.719716ms)
|
||||
✔ a value the unit cannot carry is refused, for every flag (2654.670974ms)
|
||||
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (98.265119ms)
|
||||
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (317.914722ms)
|
||||
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (48.220492ms)
|
||||
✔ uninstall refuses while the unit is active, and removes it once it is not (153.708374ms)
|
||||
✔ status prints the unit state and GET /healthz on the installed port (373.010192ms)
|
||||
✔ usage: an unknown verb or flag, and start or enable, exit 4 (99.465198ms)
|
||||
✔ without --send, render is the template with its placeholders filled, byte for byte (92.76238ms)
|
||||
✔ render --send with the preview set ends ExecStart with --send and changes nothing else (102.095197ms)
|
||||
✔ --send without the whole preview set is refused and writes nothing (289.885914ms)
|
||||
✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (920.823584ms)
|
||||
✔ uninstall and status take no --send (33.827526ms)
|
||||
✔ tokens.css and icons.svg are byte copies of docs/design (123.574529ms)
|
||||
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (36.436083ms)
|
||||
✔ shipped strings are unchanged, and the web keeps its write boundary (5.219494ms)
|
||||
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (8798.348056ms)
|
||||
✔ five states on Board, Inbox, Tasks, Agents and Trail (2979.48091ms)
|
||||
✔ Queue, Business and Settings in their five states, with no move button (5937.510694ms)
|
||||
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3122.074852ms)
|
||||
✔ the server log names no config path when the system config refuses (427.400388ms)
|
||||
ℹ tests 86
|
||||
ℹ suites 0
|
||||
ℹ pass 86
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 58444.167433
|
||||
@@ -0,0 +1,132 @@
|
||||
✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (8.806402ms)
|
||||
✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.462935ms)
|
||||
✔ completed smoke replies and ordinary questions are idle, not human blockers (2.187346ms)
|
||||
✔ only an explicit first-line input request makes a finished reply waiting (0.645684ms)
|
||||
✔ tool activity, user text, errors and unfinished turns override attention text (0.38779ms)
|
||||
✔ STOP access failure is unknown, not absence, under a non-root identity (64.46644ms)
|
||||
✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (10.512806ms)
|
||||
✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (6.889465ms)
|
||||
✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (22.042536ms)
|
||||
✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (1.210062ms)
|
||||
✔ server rescans connector discovery and refuses HTTP reply without transport (48.132755ms)
|
||||
✔ connector session links and linked directories are not read (1.494285ms)
|
||||
✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (7.200191ms)
|
||||
✔ CLI print uses the relaunch notice instead of old current preview (123.820333ms)
|
||||
✔ connector owner and fixed task never inherit a native relaunch notice (8.902705ms)
|
||||
✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (5.595175ms)
|
||||
✔ loadConfig: missing file throws ConfigError (2.611765ms)
|
||||
✔ loadConfig: invalid JSON throws ConfigError (0.523274ms)
|
||||
✔ loadConfig: missing dataRoot throws ConfigError (1.056674ms)
|
||||
✔ loadConfig: relative dataRoot throws ConfigError (0.438189ms)
|
||||
✔ loadConfig: valid config returns dataRoot (1.22937ms)
|
||||
✔ findNewestSession: picks the newest by mtime among two files (0.822041ms)
|
||||
✔ findNewestSession: finds files in nested subdirectories (0.592264ms)
|
||||
✔ findNewestSession: returns null for a missing dir (0.235573ms)
|
||||
✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (1.243053ms)
|
||||
✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (3.636023ms)
|
||||
✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (1.092533ms)
|
||||
✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (2.252719ms)
|
||||
✔ deriveState: full state table (0.371373ms)
|
||||
✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.677895ms)
|
||||
✔ rule: newest entry is a tool result with no assistant text after it is working (0.571392ms)
|
||||
✔ rule: a finished ordinary turn is idle, even if it says your move (0.537508ms)
|
||||
✔ task: the first user message of the session, from text blocks (0.548119ms)
|
||||
✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.543923ms)
|
||||
✔ task: no user message in the log means null (shown as unknown), never a guess (0.577263ms)
|
||||
✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.856434ms)
|
||||
✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (1.227326ms)
|
||||
✔ scan: the written record carries task, workspace and activeProject (1.081902ms)
|
||||
✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (1.326722ms)
|
||||
✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.847956ms)
|
||||
✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (2.62111ms)
|
||||
✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.650101ms)
|
||||
✔ loadRegistrations: a missing seatsDir gives empty lists (0.233031ms)
|
||||
✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (1.220329ms)
|
||||
✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (0.525574ms)
|
||||
✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (1.16886ms)
|
||||
✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.380804ms)
|
||||
✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.758853ms)
|
||||
✔ scanAgent: ageSeconds is computed from the injected now (0.35722ms)
|
||||
✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.193075ms)
|
||||
✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.494791ms)
|
||||
✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.499074ms)
|
||||
✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.168336ms)
|
||||
✔ scan: relative boardDir throws ConfigError (0.170587ms)
|
||||
✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (119.711221ms)
|
||||
✔ CLI: missing config exits 2 with a refused: message (87.829152ms)
|
||||
✔ CLI: unknown command exits 2 (80.53307ms)
|
||||
✔ CLI: unknown --liveness value exits 2 (82.357108ms)
|
||||
✔ panesRunPi: true when any trimmed line equals 'pi' (0.290136ms)
|
||||
✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.087237ms)
|
||||
✔ tmuxIsAlive: a pane running pi is alive (0.287611ms)
|
||||
✔ tmuxIsAlive: session exists but pi has exited is not alive (0.089416ms)
|
||||
✔ tmuxIsAlive: no such tmux session is not alive (0.061193ms)
|
||||
✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.074346ms)
|
||||
✔ tmuxIsAlive: passes -L <socket> only when a socket is given (0.123467ms)
|
||||
✔ parsePanes: one pane per line, command and optional tab-separated path (0.092375ms)
|
||||
✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.084871ms)
|
||||
✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.125351ms)
|
||||
✔ loadSeen: missing file returns {} (0.177346ms)
|
||||
✔ loadSeen: invalid JSON throws ConfigError (0.296913ms)
|
||||
✔ loadSeen: a JSON array throws ConfigError (0.246988ms)
|
||||
✔ loadSeen: a non-string value throws ConfigError (0.246702ms)
|
||||
✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.413062ms)
|
||||
✔ markSeen: seen false deletes the key (0.365278ms)
|
||||
✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.261585ms)
|
||||
✔ markSeen: project containing '/' throws ConfigError (0.162557ms)
|
||||
✔ markSeen: non-boolean seen throws ConfigError (0.140846ms)
|
||||
✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.374921ms)
|
||||
✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.381647ms)
|
||||
✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.276842ms)
|
||||
✔ scanAgent: an error-state session with a matching mark is seen (0.339719ms)
|
||||
✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.622029ms)
|
||||
✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.36288ms)
|
||||
✔ taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered (1.209232ms)
|
||||
✔ taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present (1.098121ms)
|
||||
✔ taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value (1.365887ms)
|
||||
✔ isLoopbackHost: recognizes loopback hosts (2.141627ms)
|
||||
✔ isLoopbackHost: rejects non-loopback hosts (7.981869ms)
|
||||
✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (7.702138ms)
|
||||
✔ startServer: serves page, healthz, and a rescanning /api/board (64.103893ms)
|
||||
✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (16.716069ms)
|
||||
✔ startServer: /api/board returns 500 JSON with an error field when scan throws (4.491647ms)
|
||||
✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (96.35573ms)
|
||||
✔ CLI: serve rejects a non-numeric --port with exit 2 (86.505423ms)
|
||||
✔ CLI: scan still works after the async cli refactor (84.169033ms)
|
||||
✔ CLI: live serve prints its URL and answers /healthz (91.5463ms)
|
||||
✔ page.html: esc() escapes every HTML-significant character (0.85274ms)
|
||||
✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (13.696426ms)
|
||||
✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (4.311734ms)
|
||||
✔ POST /api/seen with invalid JSON returns 400 (5.066119ms)
|
||||
✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (4.451395ms)
|
||||
✔ POST /api/seen with a missing agent returns 400 (1.986467ms)
|
||||
✔ POST /api/board returns 405; PUT /api/seen returns 405 (3.038442ms)
|
||||
✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (75.731001ms)
|
||||
✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.433961ms)
|
||||
✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.366825ms)
|
||||
✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.211486ms)
|
||||
✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.159982ms)
|
||||
✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.410846ms)
|
||||
✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (0.758226ms)
|
||||
✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (34.628056ms)
|
||||
✔ POST /api/reply: a registration with a tmux socket adds -L <socket> (38.811255ms)
|
||||
✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (41.721215ms)
|
||||
✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (22.915415ms)
|
||||
✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (6.638927ms)
|
||||
✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (0.241283ms)
|
||||
✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.757205ms)
|
||||
✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (3.270206ms)
|
||||
✔ page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it (0.446774ms)
|
||||
✔ Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers (10.693905ms)
|
||||
✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (40.9639ms)
|
||||
✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (10.339324ms)
|
||||
✔ every refusal code the reader can raise has an HTTP status (1.662914ms)
|
||||
✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (78.776893ms)
|
||||
ℹ tests 124
|
||||
ℹ suites 0
|
||||
ℹ pass 124
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1050.581652
|
||||
@@ -0,0 +1,13 @@
|
||||
start 2026-10-12T20:22:51Z
|
||||
webui rc=0 ℹ pass 86 ℹ fail 0
|
||||
control-board rc=0 ℹ pass 124 ℹ fail 0
|
||||
test-auth rc=0 selftest: 15 passed, 0 failed
|
||||
test-conductor rc=0 selftest: 17 passed, 0 failed
|
||||
test-config rc=0 selftest: 24 passed, 0 failed
|
||||
test-discord rc=0 discord suite: 66 passed, 0 failed
|
||||
test-extension-package rc=0 extension package selftest: 18 passed, 0 failed
|
||||
test-foundation rc=0 selftest: 44 passed, 0 failed
|
||||
test-queue rc=0 queue suite: 27 passed, 0 failed
|
||||
test-release rc=0 selftest: 4 passed, 0 failed
|
||||
test-task rc=0 selftest: 26 passed, 0 failed
|
||||
end 2026-10-12T20:25:43Z
|
||||
@@ -0,0 +1,17 @@
|
||||
OK status with missing harness credential exits 3 and still lists accounts
|
||||
OK status reports harness credential (read-only) + mosaic accounts
|
||||
OK api key material never reaches output
|
||||
OK oauth token material never reaches output
|
||||
OK unparseable credential file exits 2
|
||||
OK symlinked credential file exits 4
|
||||
OK env-side credential names reported
|
||||
OK env var values never reach output
|
||||
OK accounts without an accounts dir reports none and creates nothing
|
||||
OK accounts lists files and marks the active one
|
||||
OK loose account perms flagged in listing
|
||||
OK agent --auth with missing account file refuses (exit 4)
|
||||
OK agent --auth with non-0600 account file refuses
|
||||
OK agent --auth with invalid account name refuses
|
||||
OK auth.sh without valid config refuses
|
||||
|
||||
selftest: 15 passed, 0 failed
|
||||
@@ -0,0 +1,55 @@
|
||||
Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
|
||||
|
||||
You are in 'detached HEAD' state. You can look around, make experimental
|
||||
changes and commit them, and you can discard any commits you make in this
|
||||
state without impacting any branches by switching back to a branch.
|
||||
|
||||
If you want to create a new branch to retain commits you create, you may
|
||||
do so (now or later) by using -c with the switch command. Example:
|
||||
|
||||
git switch -c <new-branch-name>
|
||||
|
||||
Or undo this operation with:
|
||||
|
||||
git switch -
|
||||
|
||||
Turn off this advice by setting config variable advice.detachedHead to false
|
||||
|
||||
Not currently on any branch.
|
||||
nothing to commit, working tree clean
|
||||
Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
|
||||
|
||||
You are in 'detached HEAD' state. You can look around, make experimental
|
||||
changes and commit them, and you can discard any commits you make in this
|
||||
state without impacting any branches by switching back to a branch.
|
||||
|
||||
If you want to create a new branch to retain commits you create, you may
|
||||
do so (now or later) by using -c with the switch command. Example:
|
||||
|
||||
git switch -c <new-branch-name>
|
||||
|
||||
Or undo this operation with:
|
||||
|
||||
git switch -
|
||||
|
||||
Turn off this advice by setting config variable advice.detachedHead to false
|
||||
|
||||
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
|
||||
OK dry-run committed nothing
|
||||
OK apply: allowed change exits 0 (exit 0)
|
||||
OK apply: attribution in commit subject
|
||||
OK apply: target tree clean after commit
|
||||
OK disallowed path refused (exit 1)
|
||||
OK disallowed path: target untouched
|
||||
OK syntax gate refused broken .mjs (exit 1)
|
||||
OK syntax gate: target untouched
|
||||
OK suite failure refused (exit 1)
|
||||
OK suite failure: target reverted to clean
|
||||
OK disabled policy refused (exit 2)
|
||||
OK disabled policy: target untouched
|
||||
OK failed run refused (exit 1)
|
||||
OK failed run: target untouched
|
||||
OK missing run exits 4 (exit 4)
|
||||
OK invalid policy exits 2 (exit 2)
|
||||
|
||||
selftest: 17 passed, 0 failed
|
||||
@@ -0,0 +1,26 @@
|
||||
OK absent adapter defaults to pi
|
||||
OK adapter mock validates (exit 0)
|
||||
OK unsupported adapter exits 2 (exit 2)
|
||||
OK env exports adapter
|
||||
OK bootstrap creates default when absent (exit 0)
|
||||
OK bootstrap wrote config file
|
||||
OK bootstrap is idempotent on existing config (exit 0)
|
||||
OK bootstrap did not rewrite existing config
|
||||
OK validate missing config exits 3 (exit 3)
|
||||
OK malformed JSON exits 2 (exit 2)
|
||||
OK unsupported configVersion exits 2 (exit 2)
|
||||
OK unknown top-level key exits 2 (exit 2)
|
||||
OK unknown execution key exits 2 (exit 2)
|
||||
OK unsupported backend exits 2 (exit 2)
|
||||
OK unsupported environment exits 2 (exit 2)
|
||||
OK relative dataRoot exits 2 (exit 2)
|
||||
OK non-canonical dataRoot exits 2 (exit 2)
|
||||
OK filesystem root dataRoot exits 2 (exit 2)
|
||||
OK home directory dataRoot exits 2 (exit 2)
|
||||
OK dataRoot containing config dir exits 2 (exit 2)
|
||||
OK control character in provider exits 2 (exit 2)
|
||||
OK symlinked config file exits 2 (exit 2)
|
||||
OK env exports resolve correctly
|
||||
OK failed validation modified nothing
|
||||
|
||||
selftest: 24 passed, 0 failed
|
||||
@@ -0,0 +1,70 @@
|
||||
toolchain: node v26.8.1
|
||||
|
||||
OK syntax: packages/discord/src/approvals.mjs
|
||||
OK syntax: packages/discord/src/authorize.mjs
|
||||
OK syntax: packages/discord/src/binding.mjs
|
||||
OK syntax: packages/discord/src/cli.mjs
|
||||
OK syntax: packages/discord/src/connector.mjs
|
||||
OK syntax: packages/discord/src/context.mjs
|
||||
OK syntax: packages/discord/src/engine-pi.mjs
|
||||
OK syntax: packages/discord/src/errors.mjs
|
||||
OK syntax: packages/discord/src/gateway.mjs
|
||||
OK syntax: packages/discord/src/git.mjs
|
||||
OK syntax: packages/discord/src/journal.mjs
|
||||
OK syntax: packages/discord/src/notify.mjs
|
||||
OK syntax: packages/discord/src/rest.mjs
|
||||
OK syntax: packages/discord/src/setspark.mjs
|
||||
OK syntax: packages/discord/src/tools.mjs
|
||||
OK syntax: packages/discord/src/web.mjs
|
||||
OK syntax: packages/discord/bin/git-credential.mjs
|
||||
OK syntax: packages/discord/extension/tools.mjs
|
||||
OK syntax: packages/discord/tests/approvals.test.mjs
|
||||
OK syntax: packages/discord/tests/authorize.test.mjs
|
||||
OK syntax: packages/discord/tests/binding.test.mjs
|
||||
OK syntax: packages/discord/tests/connector.test.mjs
|
||||
OK syntax: packages/discord/tests/context.test.mjs
|
||||
OK syntax: packages/discord/tests/engine.test.mjs
|
||||
OK syntax: packages/discord/tests/fake-pi.mjs
|
||||
OK syntax: packages/discord/tests/gateway.test.mjs
|
||||
OK syntax: packages/discord/tests/git.test.mjs
|
||||
OK syntax: packages/discord/tests/helpers.mjs
|
||||
OK syntax: packages/discord/tests/journal.test.mjs
|
||||
OK syntax: packages/discord/tests/notify.test.mjs
|
||||
OK syntax: packages/discord/tests/recover.test.mjs
|
||||
OK syntax: packages/discord/tests/rest.test.mjs
|
||||
OK syntax: packages/discord/tests/setspark.test.mjs
|
||||
OK syntax: packages/discord/tests/tools.test.mjs
|
||||
OK syntax: packages/discord/tests/web.test.mjs
|
||||
OK syntax: packages/discord/fixtures/claim-worker.mjs
|
||||
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
|
||||
OK syntax: scripts/discord.sh
|
||||
OK syntax: scripts/discord-service.sh
|
||||
OK packages/discord declares no dependencies
|
||||
OK no bot-token-shaped string in packages/discord
|
||||
OK fixture binding uses placeholder ids only
|
||||
OK fixture binding validates
|
||||
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
|
||||
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
|
||||
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
|
||||
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
|
||||
OK real pi with protocol vault adds reserve_id to the git verbs
|
||||
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
|
||||
OK real pi refuses a git key on a read-only root (fail closed)
|
||||
OK real pi with the pilot flags (--no-tools) exposes no tool at all
|
||||
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test packages/discord/tests/ (ℹ pass 178)
|
||||
OK scripts/discord.sh --help exits 0
|
||||
OK scripts/discord.sh check without a binding exits 4
|
||||
OK scripts/discord.sh recover without a binding exits 4
|
||||
OK scripts/discord.sh reload without a binding exits 4
|
||||
OK scripts/discord-service.sh without a command exits 4
|
||||
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
|
||||
OK service install writes the rendered unit (0644) and leaves no temp file
|
||||
OK service install a second time reports unchanged
|
||||
OK systemd-analyze verify accepts the rendered unit
|
||||
OK service uninstall removes the unit file
|
||||
OK service install with an unknown flag exits 4
|
||||
OK service install with USER unset finishes and names the account for lingering
|
||||
|
||||
discord suite: 66 passed, 0 failed
|
||||
@@ -0,0 +1,21 @@
|
||||
OK initial ordinary-file install
|
||||
OK installed tree matches canonical source
|
||||
OK installed tree has no symlinks
|
||||
OK check detects installation drift
|
||||
OK sync refuses to overwrite installation drift
|
||||
OK check detects an extra destination file
|
||||
OK check detects an extra destination directory
|
||||
OK check rejects a destination symlink
|
||||
OK sync accepts a canonical source update
|
||||
OK updated installation matches canonical source
|
||||
scripts/test-extension-package.sh: line 14: 687408 Killed "$@" > /dev/null 2>&1
|
||||
OK forced interruption kills the replacing process
|
||||
OK next invocation recovers old consistent installation
|
||||
OK interrupted replacement rolled back
|
||||
OK sync succeeds after interruption recovery
|
||||
OK unlocked stale lock file does not block
|
||||
OK active lock refuses a concurrent sync
|
||||
OK source symlink fails closed
|
||||
OK nested second entrypoint fails closed
|
||||
|
||||
extension package selftest: 18 passed, 0 failed
|
||||
@@ -0,0 +1,53 @@
|
||||
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
|
||||
|
||||
OK syntax: scripts/foundation-inspect.mjs
|
||||
OK syntax: scripts/foundation/strict-json.mjs
|
||||
OK syntax: scripts/foundation/canonical.mjs
|
||||
OK syntax: scripts/foundation/resolve.mjs
|
||||
OK syntax: scripts/foundation/validate-record.mjs
|
||||
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
|
||||
OK syntax: scripts/foundation/canonical.test.mjs
|
||||
OK syntax: scripts/foundation/cli.test.mjs
|
||||
OK syntax: scripts/foundation/fixtures.test.mjs
|
||||
OK syntax: scripts/foundation/resolve.test.mjs
|
||||
OK syntax: scripts/foundation/strict-json.test.mjs
|
||||
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
|
||||
OK fixture generator runs
|
||||
OK checked-in fixtures/bundles equal a fresh generation
|
||||
OK checked-in fixtures/raw equal a fresh generation
|
||||
OK checked-in fixtures/index.json equal a fresh generation
|
||||
OK checked-in demo bundles equal a fresh generation
|
||||
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||
OK node --test scripts/foundation/ (ℹ pass 80)
|
||||
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
|
||||
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
|
||||
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
|
||||
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
|
||||
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
|
||||
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
|
||||
OK oracle: zero schema-column disagreements with the pinned checker
|
||||
OK oracle: strict-only profile refusals are counted and asserted
|
||||
OK demo: permitted read preview exits 0 (exit 0)
|
||||
OK demo: permitted file.change preview exits 0 (exit 0)
|
||||
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
|
||||
OK demo: revoked registration is refused (exit 3) (exit 3)
|
||||
OK demo: message is not authority (exit 3) (exit 3)
|
||||
OK usage: no arguments exits 2 (exit 2)
|
||||
OK io: missing file exits 4 (exit 4)
|
||||
OK io: directory exits 4 (exit 4)
|
||||
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
|
||||
OK bound: oversize fixture exits 2 (exit 2)
|
||||
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
|
||||
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
|
||||
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
|
||||
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
|
||||
OK text output starts with the disclaimer
|
||||
OK json output is valid JSON with result allowed and exactly the charter §7 fields
|
||||
OK json golden matches byte-for-byte
|
||||
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
|
||||
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
|
||||
OK canary never printed (bundle run and credential-file run)
|
||||
OK a non-bundle JSON file is refused at the shape gate, not read into output
|
||||
OK no field of the non-bundle file is echoed
|
||||
|
||||
selftest: 44 passed, 0 failed
|
||||
@@ -0,0 +1,35 @@
|
||||
toolchain: node v26.8.1, git version 2.55.0
|
||||
|
||||
OK syntax: packages/queue/src/cli.mjs
|
||||
OK syntax: packages/queue/src/errors.mjs
|
||||
OK syntax: packages/queue/src/io.mjs
|
||||
OK syntax: packages/queue/src/lock.mjs
|
||||
OK syntax: packages/queue/src/queue.mjs
|
||||
OK syntax: packages/queue/src/review.mjs
|
||||
OK syntax: packages/queue/src/store.mjs
|
||||
OK syntax: packages/queue/tests/commit.test.mjs
|
||||
OK syntax: packages/queue/tests/data.test.mjs
|
||||
OK syntax: packages/queue/tests/dispatch.test.mjs
|
||||
OK syntax: packages/queue/tests/helpers.mjs
|
||||
OK syntax: packages/queue/tests/lock.test.mjs
|
||||
OK syntax: packages/queue/tests/migration.test.mjs
|
||||
OK syntax: packages/queue/tests/review.test.mjs
|
||||
OK syntax: packages/queue/tests/store.test.mjs
|
||||
OK syntax: packages/queue/tests/write.test.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
|
||||
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
|
||||
OK syntax: scripts/queue-commit.sh
|
||||
OK syntax: scripts/git-hooks/pre-commit
|
||||
OK syntax: scripts/mosaic
|
||||
OK queue-commit.sh, the guard and scripts/mosaic are executable
|
||||
OK packages/queue declares no dependencies
|
||||
ℹ tests 150
|
||||
ℹ pass 150
|
||||
ℹ fail 0
|
||||
OK node --test packages/queue/tests/
|
||||
OK scripts/mosaic queue help
|
||||
skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r95w) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
|
||||
|
||||
queue suite: 27 passed, 0 failed
|
||||
@@ -0,0 +1,7 @@
|
||||
OK valid RELEASE resolves (exit 0)
|
||||
OK invalid RELEASE exits 1 (exit 1)
|
||||
OK missing RELEASE exits 1 (exit 1)
|
||||
OK valid RELEASE leaves image tag consistent with version
|
||||
skip state-machine cases (docker daemon unavailable)
|
||||
|
||||
selftest: 4 passed, 0 failed
|
||||
@@ -0,0 +1,32 @@
|
||||
OK valid task validates (exit 0)
|
||||
OK unknown task key exits 2 (exit 2)
|
||||
OK unsupported taskVersion exits 2 (exit 2)
|
||||
OK invalid task id exits 2 (exit 2)
|
||||
OK empty prompt exits 2 (exit 2)
|
||||
OK NUL in expectExact exits 2 (exit 2)
|
||||
OK out-of-range timeout exits 2 (exit 2)
|
||||
OK missing mission file exits 4 (exit 4)
|
||||
OK task with valid mission validates (exit 0)
|
||||
OK invalid mission exits 2 (exit 2)
|
||||
OK validate missing task exits 4 (exit 4)
|
||||
OK validation does not modify the task file
|
||||
OK prune dry-run exits 0 (exit 0)
|
||||
OK dry-run deleted nothing
|
||||
OK prune --keep=2 --yes removes oldest (exit 0)
|
||||
OK kept exactly 2 newest runs
|
||||
OK newest run kept, oldest pruned
|
||||
OK append-only receipt written (3 entries)
|
||||
OK sessions/workspaces untouched by prune
|
||||
OK prune with invalid keep exits 4 (exit 4)
|
||||
skip adapter seam cases (docker daemon unavailable)
|
||||
skip workspace/capability cases (docker daemon unavailable)
|
||||
skip live task cases (docker unavailable)
|
||||
OK onboard without name exits 4 (non-interactive) (exit 4)
|
||||
OK onboard --name renders profile (exit 0)
|
||||
OK profile written
|
||||
OK canon structure: required filled, optional placeholdered
|
||||
OK canon sections present
|
||||
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
|
||||
OK no agent identity on headless run
|
||||
|
||||
selftest: 26 passed, 0 failed
|
||||
@@ -0,0 +1,95 @@
|
||||
✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (9433.155574ms)
|
||||
✔ a redirect answer on a Board read, a bus read and a send signs the page out: one line, nothing stale, no further reads (5507.440996ms)
|
||||
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (6524.955107ms)
|
||||
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
|
||||
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (9877.584687ms)
|
||||
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (13315.25232ms)
|
||||
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (287.751787ms)
|
||||
✔ decisions route serves the roles and recent lists from the reader view (250.744172ms)
|
||||
✔ credentials route serves the broker credential status from the reader view (163.132809ms)
|
||||
✔ replies route serves the replies to one message from the reader view (461.99498ms)
|
||||
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (244.818732ms)
|
||||
✔ bus refusals map to statuses and never carry a path or a stack (88.828857ms)
|
||||
✔ humanCall speaks the human transport protocol without blocking the server (3143.126699ms)
|
||||
✔ busReader takes --business, else the live bus host, else refuses (789.878043ms)
|
||||
✔ humanCall kills a transport that runs past its timeout (582.177326ms)
|
||||
✔ serve refuses a --business value that is not a business id (1572.105066ms)
|
||||
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (6113.555172ms)
|
||||
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5078.995254ms)
|
||||
✔ conversation view: a newer session with no readable history keeps the marker (3277.29599ms)
|
||||
✔ conversation view: seats without history say so and offer no reply (1967.384852ms)
|
||||
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (5530.479682ms)
|
||||
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (56488.905683ms)
|
||||
✔ preview options are all three or none, and each is checked (13.629668ms)
|
||||
✔ the CLI refuses a partial or invalid preview flag set at start (3779.765078ms)
|
||||
✔ the CLI with all three flags serves the preview mode (543.83694ms)
|
||||
✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (81.864526ms)
|
||||
✔ the header name matches without regard to case, and the value compare is exact (11.404205ms)
|
||||
✔ the public name with the header reads the bus views, and the Host and Origin are checked first (101.839414ms)
|
||||
✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (42.212528ms)
|
||||
✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (8.210837ms)
|
||||
✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (75.634941ms)
|
||||
✔ a repeated Host is 400 before any other check, in preview mode and without it (39.813223ms)
|
||||
✔ without the preview flags the public name is refused and writes still reach the board (16.35174ms)
|
||||
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (2.097427ms)
|
||||
✔ credential state from the date alone, as the bus states it (0.278317ms)
|
||||
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3669.920708ms)
|
||||
✔ queue: a refused read fails closed with the store's text, redacted (1310.683471ms)
|
||||
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (5734.047113ms)
|
||||
✔ business: names, vars on the allowlist, authority, credential metadata only (27.786568ms)
|
||||
✔ business: missing or invalid file refuses with the module's text, redacted (36.782231ms)
|
||||
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (30.05563ms)
|
||||
✔ settings: release, board, loopback address, notifier binding name only (1307.70981ms)
|
||||
✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (129.567958ms)
|
||||
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (6427.808244ms)
|
||||
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (23932.043133ms)
|
||||
✔ 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 (10363.100294ms)
|
||||
✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (14230.141956ms)
|
||||
✔ startServer takes send only as a function and only with the preview mode (31.657799ms)
|
||||
✔ the CLI refuses --send without the preview flags, and without a configured bus (2619.884267ms)
|
||||
✔ the CLI with --send and a configured bus sends through the human transport and logs one line (1142.016334ms)
|
||||
✔ without --send the route is read-only like every other write, and config says no send (112.228634ms)
|
||||
✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (67.327123ms)
|
||||
✔ config says send only on the public name with --send on (12.088164ms)
|
||||
✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (13.393517ms)
|
||||
✔ each request check refuses before anything is sent, and logs the refusal without the body (55.576126ms)
|
||||
✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (14.254779ms)
|
||||
✔ a crafted role name cannot forge a log line (10.43217ms)
|
||||
✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (39.957833ms)
|
||||
✔ through the human transport the request is exactly the business, message.send and the keys (332.413398ms)
|
||||
✔ against a real broker: the message is written once as a human send, and its refusals map (1271.873458ms)
|
||||
✔ loopback host and board origin fail closed (10.442761ms)
|
||||
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (268.01198ms)
|
||||
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (264.197514ms)
|
||||
✔ unreachable board reports URL; CLI rejects unsupported options (3626.932419ms)
|
||||
✔ render prints the unit without the preview flags (50.295211ms)
|
||||
✔ render carries every serve flag, the preview set included (45.023949ms)
|
||||
✔ a partial preview set is refused (90.040257ms)
|
||||
✔ a value the unit cannot carry is refused, for every flag (1728.619397ms)
|
||||
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (288.642192ms)
|
||||
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (581.656202ms)
|
||||
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (139.11428ms)
|
||||
✔ uninstall refuses while the unit is active, and removes it once it is not (832.459593ms)
|
||||
✔ status prints the unit state and GET /healthz on the installed port (1099.951478ms)
|
||||
✔ usage: an unknown verb or flag, and start or enable, exit 4 (212.379582ms)
|
||||
✔ without --send, render is the template with its placeholders filled, byte for byte (125.207505ms)
|
||||
✔ render --send with the preview set ends ExecStart with --send and changes nothing else (132.610764ms)
|
||||
✔ --send without the whole preview set is refused and writes nothing (309.927741ms)
|
||||
✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (1655.330034ms)
|
||||
✔ uninstall and status take no --send (243.45017ms)
|
||||
✔ tokens.css and icons.svg are byte copies of docs/design (2.03329ms)
|
||||
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (3.276834ms)
|
||||
✔ shipped strings are unchanged, and the web keeps its write boundary (3.533697ms)
|
||||
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (11021.104526ms)
|
||||
✔ five states on Board, Inbox, Tasks, Agents and Trail (3344.422149ms)
|
||||
✔ Queue, Business and Settings in their five states, with no move button (7800.597104ms)
|
||||
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3567.793363ms)
|
||||
✔ the server log names no config path when the system config refuses (426.200631ms)
|
||||
ℹ tests 86
|
||||
ℹ suites 0
|
||||
ℹ pass 86
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 57238.780105
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# Filbert's row 95 mutants (builder's own). Each edit must match exactly once. The candidate is
|
||||
# uncommitted in the worktree, so each file is restored from the bytes read before the edit, and
|
||||
# every touched file's sha256 is checked against its start at the end.
|
||||
# Usage: mutants.py <worktree> <out.txt>
|
||||
import hashlib, os, signal, subprocess, sys
|
||||
|
||||
WT, OUT = sys.argv[1], sys.argv[2]
|
||||
A = 'packages/webui/src/public/app.js'
|
||||
B = 'packages/webui/src/public/bus.js'
|
||||
TESTS = ['tests/board-held.test.mjs', 'tests/send-browser.test.mjs', 'tests/browser-edge.test.mjs', 'tests/browser.test.mjs']
|
||||
RD = "if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }"
|
||||
|
||||
M = [
|
||||
('app getJSON without manual', A, "const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });", "const res = await fetch(path, { cache: 'no-store' });"),
|
||||
('app api GET without manual', A, "value === undefined ? { cache: 'no-store', redirect: 'manual' }", "value === undefined ? { cache: 'no-store' }"),
|
||||
('app api POST without manual', A, "body: JSON.stringify(value), redirect: 'manual' }", "body: JSON.stringify(value) }"),
|
||||
('app api ignores a redirect', A, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n " + RD, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n"),
|
||||
('bus fetchRead without manual', B, "=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }", "=${enc(subject)}`}`, { cache: 'no-store' }); }"),
|
||||
('bus fetchRead ignores a redirect', B, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n " + RD, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n"),
|
||||
('bus fetchApi without manual', B, "res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' });", "res = await fetch(`/api/${path}`, { cache: 'no-store' });"),
|
||||
('bus send without manual', B, "body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });", "body: JSON.stringify({ to, body: text }), cache: 'no-store' });"),
|
||||
('bus config without manual', B, "fetch('/api/config', { cache: 'no-store', redirect: 'manual' })", "fetch('/api/config', { cache: 'no-store' })"),
|
||||
('accept: no signed-out guard', A, " if (out()) return; // a read that was in flight when the page signed out\n", ''),
|
||||
('error: no signed-out guard', A, ' if (err.signedOut || out()) return;\n', ''),
|
||||
('refresh reads with the toggle off', A, 'if (busy || !machine || out()) return;', 'if (busy || out()) return;'),
|
||||
('schedule polls with the toggle off', A, 'if (!paused && machine && !out()) timer', 'if (!paused && !out()) timer'),
|
||||
('refresh: no signed-out guard', A, 'if (busy || !machine || out()) return;', 'if (busy || !machine) return;'),
|
||||
('refresh re-enables after sign-out', A, "busy = false; $('refresh').disabled = out(); schedule();", "busy = false; $('refresh').disabled = false; schedule();"),
|
||||
('load: no hideMachine when off', A, ' if (!machine) hideMachine();\n', ''),
|
||||
('setting not saved', A, " try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}\n", ''),
|
||||
('setting defaults on', A, "?.machine === true; } catch {}", "?.machine !== false; } catch {}"),
|
||||
('toggle on reads nothing', A, "fresh(); refresh(); } else hideMachine();", "fresh(); } else hideMachine();"),
|
||||
('hideMachine keeps the rows', A, " for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();\n for (const id of ['waiting-count'", " for (const id of ['waiting-count'"),
|
||||
('held: no address shows blank', B, '''${c.address ? txt(c.address) : '<span class="muted">no address</span>'}''', '''${c.address ? txt(c.address) : ''}'''),
|
||||
('held: claims with no address dropped', B, 'const claims = r.rows;', 'const claims = r.rows.filter(c => c.address);'),
|
||||
('Message without send', B, "</td>${canSend ? `<td>${typeof c.role", "</td>${true ? `<td>${typeof c.role"),
|
||||
('Message keeps the old role', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { focusSend = true;'),
|
||||
('Message: no focus in the form', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { draft.to = message.dataset.messageRole;'),
|
||||
('bus render: no signed-out guard', B, " clearTimeout(timer);\n if (out()) return;\n", " clearTimeout(timer);\n"),
|
||||
('bus schedule polls signed out', B, "if (!paused() && !out()) timer", "if (!paused()) timer"),
|
||||
('board render draws after a signed-out read', B, " if (failed.some(err => err.code === 'signed-out')) return;\n", ''),
|
||||
('bus view render draws a signed-out read', B, " if (err.code === 'signed-out') return;\n", ''),
|
||||
('send after sign-out fetches', B, " if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'", " if (false) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'"),
|
||||
('send redirect does not sign out', B, " if (res?.type === 'opaqueredirect') signOut();\n", ''),
|
||||
('bus listener keeps the held rows', B, " $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;\n", " $('held-count').textContent = '–'; heldHtml = null;\n"),
|
||||
('bus listener keeps the bus page', B, " for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();\n", ''),
|
||||
('app listener keeps the sessions', A, " for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();\n", ''),
|
||||
('app listener: refresh stays enabled', A, " $('refresh').disabled = $('pause').disabled = true;\n", ''),
|
||||
]
|
||||
|
||||
env = dict(os.environ, TMPDIR=os.path.expanduser('~/filbert-scratch/tmp'), DOCKER_HOST='unix:///nonexistent.sock')
|
||||
start = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
|
||||
out = open(OUT, 'w')
|
||||
killed = total = 0
|
||||
for name, path, old, new in M:
|
||||
full = os.path.join(WT, path)
|
||||
orig = open(full, 'rb').read()
|
||||
src = orig.decode()
|
||||
if src.count(old) != 1:
|
||||
out.write(f'BADEDIT {name}: {src.count(old)} matches\n'); out.flush(); continue
|
||||
open(full, 'w').write(src.replace(old, new))
|
||||
try:
|
||||
total += 1
|
||||
# Its own process group: a timeout kills node --test's file runners and their browsers too.
|
||||
p = subprocess.Popen(['node', '--test', *TESTS], cwd=os.path.join(WT, 'packages/webui'), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
|
||||
try:
|
||||
stdout = p.communicate(timeout=900)[0]
|
||||
dead = p.returncode != 0
|
||||
fails = [l.strip() for l in stdout.splitlines() if l.startswith('not ok')][:3]
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL); p.communicate()
|
||||
dead, fails = True, ['timeout']
|
||||
killed += dead
|
||||
out.write(f"{'KILLED ' if dead else 'SURVIVED'} {name}{' <- ' + ' | '.join(fails) if fails else ''}\n"); out.flush()
|
||||
finally:
|
||||
open(full, 'wb').write(orig)
|
||||
end = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
|
||||
out.write(f'{killed}/{total} killed; files restored: {start == end}\n')
|
||||
@@ -0,0 +1,36 @@
|
||||
SURVIVED app getJSON without manual
|
||||
KILLED app api GET without manual
|
||||
SURVIVED app api POST without manual
|
||||
KILLED app api ignores a redirect
|
||||
KILLED bus fetchRead without manual
|
||||
KILLED bus fetchRead ignores a redirect
|
||||
SURVIVED bus fetchApi without manual
|
||||
KILLED bus send without manual
|
||||
KILLED bus config without manual
|
||||
KILLED accept: no signed-out guard
|
||||
SURVIVED error: no signed-out guard
|
||||
KILLED refresh reads with the toggle off
|
||||
SURVIVED schedule polls with the toggle off
|
||||
SURVIVED refresh: no signed-out guard
|
||||
KILLED refresh re-enables after sign-out
|
||||
KILLED load: no hideMachine when off
|
||||
KILLED setting not saved
|
||||
KILLED setting defaults on
|
||||
KILLED toggle on reads nothing <- timeout
|
||||
KILLED hideMachine keeps the rows
|
||||
KILLED held: no address shows blank
|
||||
KILLED held: claims with no address dropped
|
||||
KILLED Message without send
|
||||
KILLED Message keeps the old role
|
||||
KILLED Message: no focus in the form
|
||||
KILLED bus render: no signed-out guard
|
||||
SURVIVED bus schedule polls signed out
|
||||
SURVIVED board render draws after a signed-out read
|
||||
SURVIVED bus view render draws a signed-out read
|
||||
KILLED send after sign-out fetches
|
||||
KILLED send redirect does not sign out
|
||||
KILLED bus listener keeps the held rows
|
||||
KILLED bus listener keeps the bus page
|
||||
KILLED app listener keeps the sessions
|
||||
KILLED app listener: refresh stays enabled
|
||||
26/35 killed; files restored: True
|
||||
@@ -0,0 +1,36 @@
|
||||
KILLED app getJSON without manual
|
||||
KILLED app api GET without manual
|
||||
KILLED app api POST without manual
|
||||
KILLED app api ignores a redirect
|
||||
KILLED bus fetchRead without manual
|
||||
KILLED bus fetchRead ignores a redirect
|
||||
KILLED bus fetchApi without manual
|
||||
KILLED bus send without manual
|
||||
KILLED bus config without manual
|
||||
KILLED accept: no signed-out guard
|
||||
KILLED error: no signed-out guard
|
||||
KILLED refresh reads with the toggle off
|
||||
SURVIVED schedule polls with the toggle off
|
||||
SURVIVED refresh: no signed-out guard
|
||||
KILLED refresh re-enables after sign-out
|
||||
KILLED load: no hideMachine when off
|
||||
KILLED setting not saved
|
||||
KILLED setting defaults on
|
||||
KILLED toggle on reads nothing <- timeout
|
||||
KILLED hideMachine keeps the rows
|
||||
KILLED held: no address shows blank
|
||||
KILLED held: claims with no address dropped
|
||||
KILLED Message without send
|
||||
KILLED Message keeps the old role
|
||||
KILLED Message: no focus in the form
|
||||
KILLED bus render: no signed-out guard
|
||||
SURVIVED bus schedule polls signed out
|
||||
SURVIVED board render draws after a signed-out read
|
||||
SURVIVED bus view render draws a signed-out read
|
||||
KILLED send after sign-out fetches
|
||||
KILLED send redirect does not sign out
|
||||
KILLED bus listener keeps the held rows
|
||||
KILLED bus listener keeps the bus page
|
||||
KILLED app listener keeps the sessions
|
||||
KILLED app listener: refresh stays enabled
|
||||
30/35 killed; files restored: True
|
||||
@@ -0,0 +1,2 @@
|
||||
mutants start 2026-10-12T21:03:18Z
|
||||
mutants end 2026-10-12T21:27:35Z rc=0
|
||||
Reference in New Issue
Block a user