feat(webui): read-only Queue, Business and Settings views (#1543, row 54)

The Console gains three read-only views. Queue lists every row from
rows() in packages/queue (lead decision 83: the same lock-free read as
`queue list`, writing nothing), run in a child with a timeout and an
output cap; a row page shows the full row. Business shows names, an
allowlist of vars, arbiters, authority per action and credential
metadata (service, account, role, date, never a value, file or
variable). Settings shows RELEASE, the board origin and the notifier
binding. Every route is GET only, and every string in a body or
refusal passes a redactor: the config directory and dataRoot become
<config> and <dataRoot>, other absolute, ~/ and file:// paths <path>,
and 17 to 20 digit runs <id>. A queue-read that fails to start sends a
fixed message, never node's stderr.

Four fixes to HEAD behaviour, each with a test: the bus view's refresh
timer is cleared at render, a same-page refresh keeps focus on the H1,
#conv-pick is emptied when a conversation opens, and error() returns
focus to <main> only when something had focus. Filbert's T8 tests the
failed first read.

Dewey built it in two rounds. Round 1 (dba2429e) got changes from
Filbert (27185: After rendered [object Object], ~/ and :/ paths leaked)
and Darkwing (27186: Arbiters always none, queue-read import failure
leaked a file:// path and stack). Round 2 (afb2ae0e) was approved by
Filbert (27190) and Darkwing (27191, correction 27192). Sage's gate on
d64f434f plus the candidate: 13 package node suites 0 failed (queue
149, webui 39), every scripts/test-*.sh 0 failed (task 98/0 with
Docker, 26/0 without; release 14/0), build-tokens --check current.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 17:11:17 -05:00
co-authored by Claude Opus 5.5
parent 0a329be00b
commit cbd79cf666
15 changed files with 1126 additions and 38 deletions
@@ -0,0 +1,14 @@
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
c6880e55f5590b9801066e56b9cf3b76be633b60df4141a83d7701dc3d45af73 packages/webui/README.md
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
5a611e2339fa9e31c489d24460f82852ca2bc892f07265ffdc5eca03b579541b packages/webui/src/public/app.js
a640185c5ca1458135e2297d1fb1059c893a1269b0ff20cfb3044ce297588df2 packages/webui/src/public/bus.js
4f65ab7f6a446b0ffd3c666b1ac1a726fbd5de0814a43b19f566a844e40163b0 packages/webui/src/queue-read.mjs
95767269e5971d66acea98271a028ee6ebc1540dd17849a23bdaaa7ba29b42e8 packages/webui/src/reads.mjs
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
9221692892ac56d7097128bd88dd34b76dc39861a3ff3996eb9eae999751775b packages/webui/tests/reads-fixture.mjs
47a9931cdabfa2e164bd62e5f5ca51fbf05273211e539ef42659926274f946d9 packages/webui/tests/reads.test.mjs
85f3bd247e6f99da717a51d71db13b65d511fffa8f0232ecbafa6c5000430c4e packages/webui/tests/shell.test.mjs
35f437bcafc5ae6bc698581a0d722f6154b092cc633973e492aede0ea29eb8f4 packages/webui/tests/views.test.mjs
+5
View File
@@ -286,6 +286,11 @@ lock. A file ahead of the witness prints `rev N visible, not confirmed
durable`. Any other disagreement takes the lock and rechecks before
reporting, so a write in progress is never reported as lost history.
`rows(opts)` in `store.mjs` is the same read for module callers, with no
CLI verb: every row as an object, as `show` prints it, in `list` order,
with the notes `list` prints. The Console's queue view uses it (row 54,
lead decision 83).
## Known windows and limits
- **Check to rename.** A write checks that `queue.json` is unchanged since
+7
View File
@@ -811,6 +811,13 @@ export function list(opts) {
return { out, err: r.notes, code: 0 };
}
// The rows as objects, for module callers (the Console's queue view, row 54,
// lead decision 83). Same unlocked read as list; writes nothing.
export function rows(opts) {
const r = readState(opts);
return { rows: rowsArray(r.state), err: r.notes, code: 0 };
}
export function show(opts, id) {
const r = readState(opts);
const row = r.state.rows.get(id);
+18
View File
@@ -334,6 +334,24 @@ test("a reader paused between the witness and the file while a writer finishes:
assert.deepEqual(r.err, ["rev 1 visible, not confirmed durable"]);
});
test("rows: every row as show prints it, in list order; writes nothing; same notes and refusals as list", async (t) => {
const { repo, m } = await ready(t);
const files = () => [read(repo.queuePath), read(repo.viewPath), read(join(repo.gitDir, "mosaic-queue.head"))];
const before = files();
const r = m.store.rows(o(repo));
assert.equal(r.code, 0);
assert.deepEqual(r.err, []);
assert.deepEqual(r.rows.map((row) => `${row.id}\t${row.state}\t${row.owner}\t${row.piece}`), m.store.list(o(repo)).out);
for (const row of r.rows) assert.deepEqual(row, JSON.parse(m.store.show(o(repo), row.id).out[0]));
assert.deepEqual(files(), before);
const hook = (n) => { if (n === "reader-between") m.store.mutate(o(repo), note(9, "one", "note-9-000001")); };
const tail = m.store.rows(o(repo, { hook }));
assert.deepEqual(tail.err, ["rev 1 visible, not confirmed durable"]);
assert.equal(tail.rows.find((row) => row.id === 9).note, "one");
writeFileSync(repo.queuePath, read(repo.queuePath).replace('"note": "one"', '"note": "two"'));
throwsCode(() => m.store.rows(o(repo)), 2, /do not equal the replay/);
});
test("file-then-witness order forced by a hook: the locked recheck prevents a false report", async (t) => {
const { repo, m } = await ready(t);
const hook = (n) => { if (n === "reader-between") m.store.mutate(o(repo), note(9, "one", "note-9-000001")); };
+102 -10
View File
@@ -40,7 +40,8 @@ bytes. Do not expose either unauthenticated server through a public proxy.
with a warning. There are no automatic action retries.
- Palette and appearance come from `tokens.css` (see "Shell" below) and persist
in this browser when local storage is available. Appearance defaults to
System. No settings screen is added.
System. Settings (row 54) shows the same control and nothing else that
changes.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
@@ -52,7 +53,8 @@ bytes. Do not expose either unauthenticated server through a public proxy.
or a rewritten file each shows a marker with a button to open the other
history. Reloading after a rewrite keeps the branch the view was on; if that
branch is gone, the view opens the latest one and says so. Session lists
older sessions for the seat. Only repository Pi seats
older sessions for the seat, and is empty while the list is read or when
that read fails. Only repository Pi seats
have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector
@@ -90,9 +92,10 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
open sections and the copy status survive a refresh.
- A failed read keeps the last good answer for that read and says it is old,
with the time it was read. A refusal (403, `not-configured` or `no-bus-host`)
never shows kept rows (row 53), and it drops every kept bus read: the Inbox
count and the palette's decisions and tasks go too, until a read succeeds.
with the time it was read. A refusal (403, `not-configured`, `no-bus-host`,
or row 54's `queue-refused`) never shows kept rows (row 53), and it drops
every kept read: the Inbox count and the palette's decisions, tasks and
queue rows go too, until a read succeeds.
A page never read, or refused, shows the failure instead,
titled "Bus refused the read" (403: the bus refused the Console's read, for
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
@@ -118,8 +121,8 @@ only: no new data, route or write.
(v2.304); `assets/fonts/jetbrains-mono-sources.txt` has the URL and sha256
of the archive and each file, and `jetbrains-mono-OFL.txt` the licence.
Manrope has no 800 file here, so 800 headings render at 700.
- Ctrl+K (or the search field) opens the palette over views, inbox decisions
and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow
- Ctrl+K (or the search field) opens the palette over views, inbox decisions,
tasks and (row 54) queue rows from the last queue read; arrows move, Enter opens, Esc closes and returns focus. Arrow
keys also move between task links in the bus tables.
- Copy reports in a toast. If the clipboard is unavailable, the toast stays
until dismissed and the command is selected for Ctrl+C. When no toast shows,
@@ -132,11 +135,54 @@ only: no new data, route or write.
the board was not read. A failed read with no scan held, after a refusal or
before the first scan, shows the board empty and says the read failed; it
never brings back rows from a refused scan. A refusal also closes an open
conversation and clears its history.
conversation and clears its history. If that removes what had focus, focus
moves to the page.
- From 760px the footer is a one-line status bar and the side column fits
between it and the command bar. Below 760px the section list is one strip
that scrolls inside itself.
## Queue, Business and Settings (row 54, #1543)
Three read-only views, in the section list and the palette. Each has the
same five states as the bus views: loading (the skeleton names the source),
normal, empty, a failed read over kept data (labelled stale, with its time),
and a refusal that shows nothing kept. The command bar names the source:
"Queue read …", "Business not read: refused". A refusal shows the code in
mono and the module's own message, redacted by the server.
- `#/queue` lists every row with its state, owner, reviewers, brief and last
update, filterable by state. `#/queue/<id>` adds the gate, issues, claim,
`after`, note and review rounds. Rows move only with
`scripts/mosaic queue move` in a terminal; the row page shows that command
with a Copy button and has no button that moves a row. The brief is shown
as its path and anchor, because Console serves no repository files.
- `#/business` shows the business named by `--business`, else the running
bus host's business: the human, the arbiters as instance and kind ("pm
(delivery), cto (technical)"), projects, launch rules, the vars
on an allowlist, each role instance with its holder and harness, the
authority of each instance for each action as the business package
classifies it (`resolveInstance`, `classify`), and credentials as
metadata only: service, account, role, expiry or rotate-by date, and a
state worked out from the date alone. A missing or invalid business file
is a `not-configured` refusal with the business module's own text.
- `#/settings` shows appearance (the row 53 control, kept in this browser),
the notifier binding by name only, and about: the Console's loopback
address, `RELEASE`, the business and the board URL. A Settings refusal
still shows appearance, because it needs no read.
Console never reads a token, a token file or a credential variable, and no
response, page or log carries one, a file path or a Discord channel, guild or
user id. The server redacts every message and string field before it answers
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
startup log goes through the same redactor. A path starts at the beginning
of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
Known gap: a path stops at the first space, so a path containing a space is
redacted only up to that space and the rest shows. A bare `~` or `~user/`
is not treated as a path, and neither is a relative path.
## Data and boundaries
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
@@ -165,6 +211,25 @@ that can't be used. The bus needs the system config
(`~/.config/mosaic-dev/config.json`); without it the board still serves and
the bus routes answer `not-configured`.
GET `/api/queue`, `/api/queue/<id>`, `/api/business` and `/api/settings` are
row 54's reads (`src/reads.mjs`), under the same Host, Origin and CSP rules.
Any other method is 405, and a queue id that is not a positive integer is
400. The queue is read in a child process (`src/queue-read.mjs`) through
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
read as `queue list`, with no write and no network. Like `list`, it takes
the queue lock only to recheck a disagreement before reporting it. The child
imports the store inside its `try`, so a store that fails to load reports
"the queue read failed". Only the store's exit 2 (invalid data or refused)
forwards the child's text, as `queue-refused`; any other exit is the fixed
`read-failed` message "the queue read failed (exit N)", because that stderr
may be Node's own, with a `file://` path and a stack. The business
read uses `packages/business` in process. No read writes, calls out or adds a
dependency. Answers carry `at`; failures are `{ error, message }` with 503
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
answers 200 even without a system config, so appearance always works; its
notifier part then says `not-configured`.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
@@ -182,6 +247,7 @@ WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/shell.test.mjs
node --test packages/webui/tests/reads.test.mjs packages/webui/tests/views.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -215,8 +281,9 @@ the human CLI against a live bus.
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
strings, and in the browser at 400px: System mode under an emulated dark and
light OS, the empty, stale and refused states of the board and the bus views
(a board refusal with the inspector open and a project picked, then a failed
read, and a refusal with a conversation open), not found, the palette, the toast,
(a failed first read drawn empty, a board refusal with the inspector open and a project picked, then a failed
read, a failed session list, and a refusal with a conversation open and where
focus lands after it), not found, the palette, the toast,
keyboard and visible focus, the section strip at 360 and 400px, no sideways
scroll, no script error and no request other than GET. A second test walks
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
@@ -225,6 +292,31 @@ read), normal, a failed read over kept rows, and a refusal by the bus
refusal empties the palette and the Inbox count, from a view, from the
palette's own reads and from the board page, and that palette labels are text.
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
queue whose row note names an id and token paths shaped like real notes
(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
`<…>` case is in the redactor test), and a business,
notifier config and Discord binding that hold token files, an environment
variable name, an agent `model` var holding a path, and guild, channel, bot
and user ids. Row 9's After entry has the `{id, when}` shape the real queue
stores. `reads.test.mjs` checks each
route's body and status, the redactor, the credential state, methods and
Origin, and that no body carries any seeded value or the temporary directory;
that a `store.mjs` with a syntax error, or none, gives the fixed message with
no path; and that the child's timeout, output cap and non-2 exits hold, over a
fake child script.
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
five states over a stub read, with each refusal code the view can meet, at
400px; checks that a refusal leaves no queue row in the palette, that no view
has a control but Copy and Read again, and that the row page shows the
`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
select, and that the palette keeps its queue rows after its own inbox and tasks
reads answer; then, over the seeded fixture, that the Arbiters line reads the
business file's object, that neither the page nor
any response it read carries a seeded value, that a missing or invalid
business file shows the module's text, and that the startup log names no
config path.
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
+12 -5
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env node
import { startServer, DEFAULT_BOARD } from './serve.mjs';
import { busReader } from './bus.mjs';
import { loadSystem, socketPath } from '../../cli/src/config.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]';
@@ -24,13 +26,18 @@ try {
}
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
let bus = null, busLine;
// The Queue, Business and Settings reads (row 54) need no bus; without
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
let bus = null, busLine, system = null;
try {
const { dataRoot } = loadSystem();
system = loadSystem();
const { dataRoot } = system;
bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) { busLine = `Bus: not configured (${err.message})`; }
const server = await startServer({ board: options.board, port: options.port, bus });
} catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; }
const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads });
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
+4 -2
View File
@@ -191,7 +191,7 @@
conv = { row: rowKey, id: null, branch: null, follow: null, messages: new Map(), markers: new Map(), sessionId: null, loading: true, polling: false, returnFocus: returnTo };
selected = null; render(); showConversation(true);
$('conv-title').textContent = r ? `${r.agent} conversation` : 'Conversation';
$('conv-meta').textContent = ''; $('conv-log').replaceChildren(); drawMarkers(); convForm();
$('conv-meta').textContent = ''; $('conv-pick').replaceChildren(); $('conv-log').replaceChildren(); drawMarkers(); convForm();
if (!sameRow) $('conv-reply').value = drafts.get(rowKey) || '';
convStatus('Loading history…');
const slash = rowKey.lastIndexOf('/'), project = r?.project ?? rowKey.slice(0, slash), agent = r?.agent ?? rowKey.slice(slash + 1);
@@ -306,7 +306,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) {
const refused = err.status === 403;
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(); } }
if (!data) {
@@ -324,6 +324,8 @@
? `<p><b>The board refused the read.</b> refused: <code>${esc(err.message)}</code> Board: ${esc(board)}. Console shows nothing rather than a guess. Use Refresh to try again.</p>`
: `<p><b>${esc(err.message)}</b> Board: ${esc(board)}. ${data ? `Showing last known data.${data.generatedAt ? ` Scanned ${esc(data.generatedAt)}.` : ''}` : 'No board data loaded.'} Use Refresh to try again.</p>`;
fresh();
// 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); }
async function refresh() {
+156 -16
View File
@@ -32,6 +32,17 @@
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) {
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 }); }
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;
}
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
function reader(live) {
const used = [];
@@ -43,6 +54,14 @@
used.push(r.at);
return r.rows;
};
get.api = async path => {
const k = `api:${path}`;
let r;
if (live) { r = await fetchApi(path); last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
return r;
};
get.oldest = () => used.filter(Boolean).sort()[0] || null;
return get;
}
@@ -206,7 +225,7 @@
// ---------------------------------------------------------------- views
let inboxCount = null;
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents']];
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
@@ -316,6 +335,99 @@
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
}
// ---------------------------------------------------------------- queue, business, settings
// Row 54 (#1543), read-only. A row moves only with `scripts/mosaic queue move` in a terminal,
// so its page shows that command and has no button for it. The business file is edited where
// it lives. On Settings only appearance changes, and only in this browser.
const readLine = () => `Read ${when(readAt)}.`;
const none = '<span class="muted">none</span>';
const QUEUE_STATES = ['queued', 'briefed', 'in-progress', 'in-review', 'waiting-on-jason', 'done', 'blocked', 'parked'];
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
const qState = s => badge(s, QSTATE[s] || 'muted');
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
// An after entry is {id, when}, as QUEUE.md writes it: "53 done".
const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
// The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
const storeNotes = notes => Array.isArray(notes) && notes.length ? `<section aria-labelledby="q-notes"><h2 id="q-notes">From the store <span class="count">${notes.length}</span></h2><ul class="s1-plain">${notes.map(n => `<li>${txt(n)}</li>`).join('')}</ul></section>` : '';
const moveHelp = 'Rows move only with <code>scripts/mosaic queue move</code> in a terminal. Console has no button for it.';
function queueRow(r) {
return `<tr><td class="s1-title"><a href="#/queue/${esc(r.id)}">${esc(r.id)} ${txt(r.piece)}</a></td><td>${qState(r.state)}</td><td>${txt(r.owner) || none}</td><td>${names(r.reviewers)}</td><td>${briefRef(r.brief)}</td><td>${when(r.updatedAt)}</td></tr>`;
}
async function queueView(get, q) {
const doc = await get.api('queue'), rows = Array.isArray(doc.rows) ? doc.rows : [];
const filter = QUEUE_STATES.includes(q.get('state')) ? q.get('state') : 'all';
const shown = filter === 'all' ? rows : rows.filter(r => r.state === filter);
const chips = `<nav aria-label="Filter the queue by state" class="chips">${['all', ...QUEUE_STATES].map(s => `<a class="chip" href="#/queue${s === 'all' ? '' : `?state=${s}`}"${filter === s ? ' aria-current="true"' : ''}>${s === 'all' ? 'All' : esc(s)} <span class="count">${s === 'all' ? rows.length : rows.filter(r => r.state === s).length}</span></a>`).join('')}</nav>`;
const table = `<div class="table-wrap" tabindex="0" role="region" aria-label="Queue rows, scroll for all columns"><table class="s1-table s1-queue"><thead><tr>${['Row', 'State', 'Owner', 'Reviewers', 'Brief', 'Updated'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${shown.map(queueRow).join('')}</tbody></table></div>`;
const body = !rows.length ? empty('The queue has no rows.', 'A row shows here after <code>scripts/mosaic queue add</code> records it in <code>docs/plans/queue.json</code>.')
: shown.length ? table : empty(`No row is ${esc(filter)}.`, 'Choose another state above.');
return `${head('Queue', `${readLine()} ${moveHelp}`)}${storeNotes(doc.notes)}${rows.length ? chips : ''}${body}`;
}
const round = r => {
const receipts = Array.isArray(r.receipts) ? r.receipts : [];
return `<li><p><b>Round ${txt(r.n)}</b> requested by ${txt(r.by)} ${when(r.at)}${r.issue ? ` on #${txt(r.issue)}` : ''}${r.candidate ? ` · candidate ${txt(r.candidate.kind)} <code>${txt(String(r.candidate.digest ?? '').slice(0, 12))}</code>` : ''}</p>
${receipts.length ? `<ul class="s1-plain">${receipts.map(x => `<li>${txt(x.reviewer)}: ${badge(x.verdict ?? 'no verdict', x.verdict === 'approve' ? 'ok' : x.verdict ? 'attn' : 'muted')} ${when(x.at)}${x.comment ? ` <span class="source">comment ${txt(x.comment)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="small muted">No verdict yet.</p>'}</li>`;
};
async function queueRowView(get, id) {
let doc;
try { doc = await get.api(`queue/${id}`); } catch (err) { if (err.code === 'not-found') return notFound(`No row ${esc(id)} in the queue.`); throw err; }
const r = doc.row || {}, rounds = Array.isArray(r.review?.rounds) ? r.review.rounds : [];
// The command names this row; the state, op and seat are the person's to fill in.
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
${storeNotes(doc.notes)}
<section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
<p><a href="#/queue">Back to the queue</a></p></section>`;
}
const CRED = { valid: 'ok', expiring: 'attn', expired: 'danger', 'rotation-due': 'danger' };
const AUTH = { within: 'within-role', cross: 'cross-role', gated: 'gated' };
const kvRows = (vars, label) => {
const e = Object.entries(vars || {});
return e.length ? `<dl class="kv">${e.map(([k, v]) => `<dt><code>${txt(k)}</code></dt><dd>${txt(Array.isArray(v) ? v.join(', ') : typeof v === 'object' && v !== null ? JSON.stringify(v) : v)}</dd>`).join('')}</dl>` : `<p class="muted">${label}</p>`;
};
async function businessView(get) {
const b = (await get.api('business')).business || {};
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
const roleRows = instances.map(i => `<tr><th scope="row">${txt(i.instance)}</th><td>${txt(i.definition)}</td><td>${txt(i.holder) || none}</td><td>${txt(i.vars?.harness) || none}${i.vars?.model ? `<span class="source">${txt(i.vars.model)}</span>` : ''}</td></tr>`).join('');
const matrix = authKnown ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Authority by action, scroll for all instances"><table class="s1-table s1-authority"><thead><tr><th scope="col">Action</th>${instances.map(i => `<th scope="col">${txt(i.instance)}</th>`).join('')}</tr></thead><tbody>${actions.map(a => `<tr><th scope="row"><code>${txt(a)}</code></th>${instances.map(i => { const c = i.authority?.[a]; return `<td>${c ? `<span class="tag tag-${AUTH[c] || 'gated'}">${txt(c)}</span>` : '<span class="muted">refused</span>'}</td>`; }).join('')}</tr>`).join('')}</tbody></table></div>`
: '<p class="muted">Authority comes from the system config with the business file. This Console could not read the system config, so it shows none.</p>';
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
<section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
<section aria-labelledby="bz-creds"><h2 id="bz-creds">Credentials <span class="count">${creds.length}</span></h2><p class="small muted">Metadata only: service, account, role and date. Console never reads a token, a token file or a variable.</p>${creds.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Credentials, scroll for all columns"><table class="s1-table s1-creds"><thead><tr>${['Service', 'Account', 'Role', 'Date', 'State'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${credRows}</tbody></table></div>` : empty('No credentials are named.', 'A credential shows here when a role instance or tracker sync names one.')}</section>`;
}
// Appearance mirrors the command bar's two selects, so app.js keeps the one copy of the rule.
function appearance() {
const p = $('palette'), m = $('mode');
const pick = (id, label, src) => `<label>${label}<select data-mirror="${id}">${[...src.options].map(o => `<option value="${esc(o.value)}"${o.value === src.value ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('')}</select></label>`;
return `<section aria-labelledby="st-look" class="panel"><h2 id="st-look">Appearance</h2><p class="small muted">Stored in this browser only. The same control is in the command bar.</p><div class="filters">${pick('palette', 'Palette', p)}${pick('mode', 'Appearance', m)}</div></section>`;
}
async function settingsView(get) {
const s = await get.api('settings'), n = s.notifier || {};
const notify = typeof n.binding === 'string'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
return `${head('Settings', `${readLine()} Only appearance changes here.`)}${appearance()}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
// Trail
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
@@ -348,11 +460,23 @@
// Not found and states
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = () => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">Reading from the bus.</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
function why(err) {
const code = `<code>${txt(err.code)}</code>`;
// The queue, business and settings reads: the module's own text, redacted by the server.
if (err.module) {
const said = {
'queue-refused': 'The queue refused the read.',
'not-configured': 'Console could not load what this view reads.',
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>, so it knows no business. Start one with <code>mosaic bus start &lt;business&gt;</code>.',
'read-failed': 'The read did not finish.',
'invalid-request': 'The Console server refused this address.',
unreachable: 'The Console server did not answer.',
}[err.code] || 'The read answered with something Console cannot use.';
return `${said} (${code})${err.code !== 'unreachable' && err.message ? `<br><span class="source">${txt(err.message)}</span>` : ''}`;
}
const say = {
'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.',
unauthenticated: 'The bus did not accept this Console as the human.',
@@ -366,19 +490,21 @@
}[err.code] || 'The bus answered with something Console cannot use.';
return `${say} (${code})`;
}
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
const failTitle = err => err.code === 'queue-refused' ? 'Queue refused the read' : err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? (err.module ? 'Nothing to read' : 'No bus to read') : 'The read failed';
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host'].includes(err.code);
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
function forget() { last.clear(); inboxCount = null; }
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : 'Read again, or come back when the bus answers.')}`;
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
function freshBus(state) {
$('fresh-bus').innerHTML = state === 'refused' ? 'Bus <span class="warn">not read: refused</span>' : state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
: `Bus read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
// The queue, business and settings pages name their own source.
function freshBus(state, source = 'Bus') {
$('fresh-bus').innerHTML = state === 'refused' ? `${esc(source)} <span class="warn">not read: refused</span>` : state === 'failed' ? `${esc(source)} <span class="warn">not read: the read failed</span>`
: `${esc(source)} read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
}
const SOURCES = { queue: 'Queue', business: 'Business', settings: 'Settings' };
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
@@ -392,6 +518,10 @@
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
if (a === 'agents' && n === 1) return agentsView(get);
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
if (a === 'business' && n === 1) return businessView(get);
if (a === 'settings' && n === 1) return settingsView(get);
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
return notFound();
@@ -403,17 +533,20 @@
function keep() {
const a = document.activeElement, view = $('bus-view');
return {
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'),
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
};
}
function restore(k) {
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
if ($('copy-status')) $('copy-status').textContent = k.status;
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry)) el.focus({ preventScroll: true });
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry || k.mirror)) el.focus({ preventScroll: true });
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
}
async function render(navigated) {
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
const g = ++gen;
document.body.classList.toggle('on-bus', !board);
@@ -427,18 +560,18 @@
if (g === gen) schedule();
return;
}
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
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); state = refusal(err) ? 'refused' : 'failed'; }
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (g !== gen) return;
freshBus(state);
freshBus(state, SOURCES[path.split('/')[1]]);
const k = !navigated && shown === route ? keep() : null;
$('bus-view').innerHTML = html; shown = route;
sections(path);
@@ -489,12 +622,13 @@
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents']];
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
const tasks = (last.get('tasks')?.rows || []).filter(r => TASK.test(String(r.task_ref))).map(r => { const t = task(r); return { href: `#/tasks/${enc(t.ref)}`, label: `#${t.id} ${t.title ?? '(no title in the current read)'}`, group: 'task' }; });
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks];
const rows = (last.get('api:queue')?.rows || []).filter(r => Number.isInteger(r.id)).map(r => ({ href: `#/queue/${r.id}`, label: `${r.id} ${r.piece ?? ''}`, group: `queue row, ${r.state}` }));
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks, ...rows];
}
function pkFill() {
const q = $('cmdk-input').value.trim().toLowerCase();
@@ -534,6 +668,12 @@
const links = [...a.closest('table').querySelectorAll('td.s1-title a')], i = links.indexOf(a);
e.preventDefault(); links[i + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
});
// Settings' appearance selects drive the command bar's, and follow them back.
document.addEventListener('change', e => {
const m = e.target.closest?.('#bus-view select[data-mirror]');
if (m) { const src = $(m.dataset.mirror); src.value = m.value; src.dispatchEvent(new Event('change')); return; }
if (e.target.id === 'palette' || e.target.id === 'mode') for (const s of document.querySelectorAll(`#bus-view select[data-mirror="${e.target.id}"]`)) s.value = e.target.value;
});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
+21
View File
@@ -0,0 +1,21 @@
// Row 54 (#1543): the Console's read of the queue, run as a child with its
// cwd at the checkout so one slow read never stalls the HTTP server. It
// calls rows() in packages/queue (lead decision 83): the same unlocked read
// as `queue list`, which writes nothing. JSON on stdout; a refusal's message
// on stderr with the store's exit code.
//
// The store is imported inside the try, so a store.mjs that fails to load
// (a half-written file in a shared checkout) prints "the queue read failed",
// never Node's own message, which names the file:// path and a stack.
let QueueError = null;
try {
({ QueueError } = await import('../../queue/src/errors.mjs'));
const { rows } = await import('../../queue/src/store.mjs');
const r = rows({});
process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`);
} catch (err) {
const refused = QueueError !== null && err instanceof QueueError;
process.stderr.write(`${refused ? err.message : 'the queue read failed'}\n`);
process.exitCode = refused && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
}
+210
View File
@@ -0,0 +1,210 @@
// Row 54 (#1543): the Console's read-only Queue, Business and Settings
// views. Each read writes nothing and calls nothing over the network.
//
// Queue: rows() in packages/queue (lead decision 83), run as a child like
// the bus read, so a slow read never stalls the HTTP server.
// Business: loadBusiness from packages/business, projected to names, vars
// on an allowlist, authority and credential metadata. A credential shows
// its service, account, role and date, never a value, file or variable.
// Settings: RELEASE, the board origin and the notifier's binding name.
//
// Every string a response carries passes through the redactor: the config
// directory and dataRoot become <config> and <dataRoot>, any other absolute
// or ~/ path <path>, and a 17 to 20 digit run (a Discord id) <id>. A path
// starts at the beginning, a space, an opening bracket, a quote, `=`, `,`,
// `<` or `:`, so `file:/x` and `file:///x` are paths and `https://host/x`
// is not. A path stops at a space, so one with a space in it is redacted
// only up to the space.
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } from '../../business/src/index.mjs';
import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs';
export class ReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
}
// HTTP status by code. 503 is "nothing to read" or a refusal to read;
// 502 an answer Console can't use.
const STATUS = { 'not-configured': 503, 'no-bus-host': 503, 'queue-refused': 503, 'read-failed': 503, 'not-found': 404, 'invalid-request': 400 };
export const readStatus = code => STATUS[code] ?? 502;
const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// V8's JSON.parse messages quote the input they failed on ("Unexpected
// token 'x', "…" is not valid JSON"). A business file is authority and
// may sit next to secrets, so the quote is never shown.
const unquote = s => s.replace(/(?:Unexpected token|")[\s\S]*is not valid JSON/g, 'the parser quoted the file here; Console does not show it');
export function redactor({ configDir = null, dataRoot = null } = {}) {
const known = [[configDir, '<config>'], [dataRoot, '<dataRoot>']].filter(([p]) => typeof p === 'string' && p.startsWith('/') && p.length > 1)
.sort((a, b) => b[0].length - a[0].length)
.map(([p, label]) => [new RegExp(`${escape(p.replace(/\/+$/, ''))}(?![\\w.-])`, 'g'), label]);
return text => {
let s = unquote(String(text));
for (const [re, label] of known) s = s.replace(re, label);
return s.replace(/(^|[\s('"`=,[{<]|:(?!\/\/[^/]))~?\/[^\s'"`),;\]}>]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
};
}
// Every string in a value, deep. Keys are left alone: they are fixed names or ids.
export function scrub(value, redact) {
if (typeof value === 'string') return redact(value);
if (Array.isArray(value)) return value.map(v => scrub(v, redact));
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrub(v, redact)]));
return value;
}
// ------------------------------------------------------------------ queue
// Runs src/queue-read.mjs under `root` with its cwd there. The store
// refuses outside the canonical checkout on branch refactor, as `list` does.
export function queueReader({ root, env = process.env, timeoutMs = 30000, maxBytes = 16 * 1024 * 1024 }) {
const script = join(root, 'packages/webui/src/queue-read.mjs');
return () => new Promise((resolve, reject) => {
let child;
try { child = spawn(process.execPath, [script], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); }
catch { return reject(new ReadError('read-failed', 'the queue read did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new ReadError('read-failed', 'the queue read did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new ReadError('invalid-response', 'the queue read printed too much')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new ReadError('read-failed', 'the queue read did not start')));
child.on('close', status => {
const said = Buffer.concat(err).toString('utf8').trim().slice(0, 2000);
if (status === null) return done(reject, new ReadError('read-failed', 'the queue read did not finish'));
// 2 is the store's "invalid data or refused": fail closed with the
// store's text. Anything else failed to run, and its stderr may be
// Node's own (a path and a stack), so it is not shown; the view keeps
// its last read.
if (status === 2) return done(reject, new ReadError('queue-refused', said || 'the queue refused the read'));
if (status !== 0) return done(reject, new ReadError('read-failed', `the queue read failed (exit ${status})`));
try {
const doc = JSON.parse(Buffer.concat(out).toString('utf8'));
if (!Array.isArray(doc?.rows) || !Array.isArray(doc?.notes)) throw new Error('shape');
done(resolve, doc);
} catch { done(reject, new ReadError('invalid-response', 'the queue read printed something that is not its JSON')); }
});
});
}
// The list carries what the table and its filters need; a row page reads
// the full row through /api/queue/<id>.
function latestRound(review) {
const r = review?.rounds?.at(-1);
if (!r) return null;
return { n: r.n, at: r.at, by: r.by, issue: r.issue, candidate: r.candidate ? { kind: r.candidate.kind, digest: r.candidate.digest } : null, receipts: (r.receipts ?? []).map(x => ({ reviewer: x.reviewer, verdict: x.verdict, at: x.at, comment: x.comment })) };
}
export const queueSummary = row => ({
id: row.id, piece: row.piece, state: row.state, previousState: row.previousState, owner: row.owner, reviewers: row.reviewers ?? [],
issues: row.issues ?? [], brief: row.brief ? { path: row.brief.path, anchor: row.brief.anchor } : null, after: row.after ?? [],
required: row.required, gateOwner: row.gateOwner, claim: row.claim, note: row.note, blockedReason: row.blockedReason,
updatedAt: row.updatedAt, updatedBy: row.updatedBy, review: latestRound(row.review),
});
// --------------------------------------------------------------- business
// The business id: --business, else the live bus host's, read each time.
export function businessId({ business = null, dataRoot = null }) {
if (business) return business;
if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no business');
let state;
try { state = readHostState(dataRoot); } catch { throw new ReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new ReadError('no-bus-host', 'no bus host is running and no --business was given');
return state.business;
}
const VAR_KEYS = ['tracker.kind', 'tracker.baseUrl', 'tracker.reconcileMinutes', 'tracker.pollSeconds', 'gitea.baseUrl', 'limits.tools', 'limits.network', 'limits.authority'];
const AGENT_KEYS = ['harness', 'model', 'thinking', 'limits.tools', 'limits.network', 'limits.authority'];
const pick = (vars, keys) => Object.fromEntries(keys.filter(k => Object.hasOwn(vars ?? {}, k)).map(k => [k, vars[k]]));
const DAY = 86400000;
// The same rule as the bus's Credentials.status(), from the date alone:
// this read never opens a token file or reads a variable.
export function credentialState(service, date, now = Date.now()) {
const left = Date.parse(`${date}T00:00:00Z`) - now;
if (service === 'gitea') return left <= 0 ? 'rotation-due' : 'valid';
return left <= 0 ? 'expired' : left < 7 * DAY ? 'expiring' : 'valid';
}
const credential = (service, ref, account, role, now) => {
const dateKind = ref.expires ? 'expires' : 'rotateBy', date = ref[dateKind];
return { service, account, role, dateKind, date, state: credentialState(service, date, now) };
};
export function businessView(b, { system = null, now = Date.now() } = {}) {
const instances = Object.entries(b.roles).map(([instance, r]) => {
let authority = null, refused = null;
if (system) {
try {
const resolved = resolveInstance({ system: systemVars(system), business: b, instance });
authority = Object.fromEntries(ACTIONS.map(a => [a, classify(resolved, a)]));
} catch (err) {
if (!(err instanceof BusinessError)) throw err;
refused = err.message;
}
}
return { instance, definition: r.definition, holder: r.holder, vars: pick(r.vars, AGENT_KEYS), authority, refused };
});
const credentials = [];
for (const [instance, r] of Object.entries(b.roles)) {
for (const [service, ref] of Object.entries(r.credentials)) credentials.push(credential(service, ref, service === 'vikunja' ? r.tracker?.bot ?? null : null, instance, now));
}
for (const [service, ref] of Object.entries(b.tracker.sync.credentials)) credentials.push(credential(service, ref, b.tracker.sync.bot, 'tracker sync', now));
return {
id: b.id, human: b.human, arbiters: b.arbiters, projects: Object.keys(b.projects), actions: [...ACTIONS],
vars: pick(b.vars, VAR_KEYS), instances, launch: b.launch, credentials,
};
}
// --------------------------------------------------------------- all three
// `system` is loadSystem() output or null. Returns { queue, row, business,
// settings }; each resolves to a redacted body or throws a ReadError whose
// message is redacted.
export function consoleReads({ root, system = null, configDir = null, rolesDir, business = null, env = process.env, queueTimeoutMs = 30000, now = () => Date.now() }) {
const dataRoot = system?.dataRoot ?? null;
const redact = redactor({ configDir, dataRoot });
const fail = (code, message) => new ReadError(code, redact(message));
const queue = queueReader({ root, env, timeoutMs: queueTimeoutMs });
const readQueue = async () => {
try { return await queue(); } catch (err) { throw err instanceof ReadError ? fail(err.code, err.message) : fail('read-failed', 'the queue read failed'); }
};
const loaded = () => {
if (!configDir) throw fail('not-configured', 'the Console has no config directory');
const id = businessId({ business, dataRoot });
try { return loadBusiness(id, { dir: configDir, rolesDir }); }
catch (err) {
if (err instanceof BusinessError) throw fail('not-configured', err.message);
throw fail('read-failed', 'the business read failed');
}
};
return {
async queue() {
const doc = await readQueue();
return scrub({ rows: doc.rows.map(queueSummary), notes: doc.notes, at: new Date(now()).toISOString() }, redact);
},
async row(id) {
const doc = await readQueue();
const row = doc.rows.find(r => r.id === id);
if (!row) throw fail('not-found', `no row ${id}`);
return scrub({ row, notes: doc.notes, at: new Date(now()).toISOString() }, redact);
},
async business() {
const b = loaded();
return scrub({ business: businessView(b, { system, now: now() }), at: new Date(now()).toISOString() }, redact);
},
async settings() {
let release = null;
try { release = readFileSync(join(root, 'RELEASE'), 'utf8').trim().slice(0, 64) || null; } catch { /* shown as unknown */ }
let id = null, notifier;
try {
id = businessId({ business, dataRoot });
if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no notifier config');
notifier = { binding: readNotifyConfig(dataRoot, id) };
} catch (err) {
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
}
return scrub({ release, business: id, notifier, at: new Date(now()).toISOString() }, redact);
},
};
}
+22 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
import { ReadError, readStatus } from './reads.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -62,7 +63,16 @@ async function busRead(res, bus, verb, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) {
// Row 54 (#1543): GET only. `reads` is consoleReads() from reads.mjs; its
// bodies and messages arrive redacted. No route here writes or calls out.
const QUEUE_ID = /^[1-9]\d{0,5}$/;
async function consoleRead(res, read) {
try { return json(res, 200, await read()); } catch (err) {
const code = err instanceof ReadError ? err.code : 'invalid-response';
return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code });
}
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
@@ -106,6 +116,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return busRead(res, bus, verb, search);
}
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/queue') return consoleRead(res, () => reads.queue());
const id = path.slice('/api/queue/'.length);
if (!QUEUE_ID.test(id)) return json(res, 400, { error: 'invalid-request', message: 'a queue row id is a positive integer' });
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
if (path === '/api/config') return json(res, 200, { board: upstream });
if (path === '/healthz') return json(res, 200, { ok: true });
+61
View File
@@ -0,0 +1,61 @@
// Row 54 (#1543): the seeded fixture the reads and views tests share. A
// business holds a Discord user id, token files, an environment variable
// name and bot ids; a data root holds a notifier config and a Discord
// binding with guild, channel and user ids and a token file; an agent's
// model var holds a path. A queue row's note names an id and token paths in
// the shapes real notes use (row 35: "~/.config/mosaic-dev/secrets/…").
// None may reach a response, the page or a log, nor may the temporary
// directory. Rows 9 and 11 have After entries in the stored {id, when} shape.
import assert from 'node:assert/strict';
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scratchRepo, genesisCommitted, mapText, MAP_ROWS } from '../../queue/tests/helpers.mjs';
import { businessDoc, writeJson } from '../../business/tests/helpers.mjs';
export const SRC = join(import.meta.dirname, '..', 'src');
export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678'];
export const day = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
export const NOTE = `token at /srv/secret/x.token for ${SNOWFLAKES[4]}; tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (receipt file:/srv/secret/r.json)`;
export const NOTE_SHOWN = 'token at <path> for <id>; tokens 0600 under <path> (receipt file:<path>)';
// A canonical scratch checkout with a committed queue, the reader script
// beside a copy of packages/queue/src, and a RELEASE file.
export function queueRepo(t) {
const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: NOTE } : r.id === 11 ? { ...r, after: [{ id: 9, when: 'done' }] } : r);
const repo = scratchRepo(t, { map: mapText(rows) });
genesisCommitted(repo);
mkdirSync(join(repo.root, 'packages/webui/src'), { recursive: true });
cpSync(join(SRC, 'queue-read.mjs'), join(repo.root, 'packages/webui/src/queue-read.mjs'));
writeFileSync(join(repo.root, 'RELEASE'), '0.0.99\n');
return repo;
}
// The business, notifier and binding files under one temporary base.
export function seeded(t, { business = true, notify = true } = {}) {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'mosaic-webui-reads-')));
t.after(() => rmSync(base, { recursive: true, force: true }));
const configDir = join(base, 'config'), dataRoot = join(base, 'data');
const doc = businessDoc(base);
doc.vars['human.discordUserId'] = SNOWFLAKES[0];
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
doc.roles.coder.credentials.vikunja.expires = day(3);
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
bindingVersion: 1, name: 'jason-dm', seat: 'sage', guildId: SNOWFLAKES[1], guildName: 'g', botUserId: SNOWFLAKES[2],
tokenFile: join(base, 'secrets', 'discord.token'), channels: [{ id: SNOWFLAKES[3], name: 'c', mode: 'open' }], users: [{ id: SNOWFLAKES[0], name: 'jason' }],
});
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
return { base, configDir, dataRoot, system, doc };
}
// Nothing secret, no id, no temporary path, in any body.
export function clean(text, ...bases) {
for (const b of bases) assert.equal(text.includes(b), false, `response names ${b}`);
for (const s of SNOWFLAKES) assert.equal(text.includes(s), false, `response carries id ${s}`);
for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret', '~/', 'secrets/mosaic-stack']) assert.equal(text.includes(s), false, `response carries ${s}`);
}
+198
View File
@@ -0,0 +1,198 @@
// Row 54 (#1543): the Queue, Business and Settings reads over the seeded
// fixture in reads-fixture.mjs. No response may carry a secret, an id or
// the temporary directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
async function serve(t, reads) {
const server = await startServer({ port: 0, reads });
t.after(() => server.close());
return `http://127.0.0.1:${server.address().port}`;
}
const get = async (base, path, init) => { const r = await fetch(base + path, init); return { status: r.status, text: await r.text() }; };
test('redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote', () => {
const r = redactor({ configDir: '/home/u/.config/mosaic-dev', dataRoot: '/home/u/.mosaic-dev' });
assert.equal(r('business file not found: /home/u/.config/mosaic-dev/businesses/a.json'), 'business file not found: <config>/businesses/a.json');
assert.equal(r('no notifier config at /home/u/.mosaic-dev/notify/a/notify.json; write'), 'no notifier config at <dataRoot>/notify/a/notify.json; write');
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> ok');
// ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path.
assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under <path> (lead decision 74)');
assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p');
assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/');
const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON');
assert.equal(quoted.includes('s3cret-value'), false);
assert.match(quoted, /^business file is not valid JSON \(<config>\/businesses\/a\.json\): the parser quoted/);
});
test('credential state from the date alone, as the bus states it', () => {
const now = Date.parse('2026-10-10T12:00:00Z');
assert.equal(credentialState('gitea', '2026-10-10', now), 'rotation-due');
assert.equal(credentialState('gitea', '2026-10-11', now), 'valid');
assert.equal(credentialState('vikunja', '2026-10-10', now), 'expired');
assert.equal(credentialState('vikunja', '2026-10-16', now), 'expiring');
assert.equal(credentialState('vikunja', '2026-10-18', now), 'valid');
});
test('queue: rows, one row, ids and methods, notes; no path or id from a row', async t => {
const repo = queueRepo(t);
const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
const list = await get(base, '/api/queue');
assert.equal(list.status, 200, list.text);
const body = JSON.parse(list.text);
assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]);
const six = body.rows.find(r => r.id === 6);
assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]);
assert.equal(six.note, NOTE_SHOWN);
assert.equal(Object.hasOwn(six, 'gate'), false);
assert.ok(Array.isArray(body.notes));
clean(list.text, repo.base);
const one = await get(base, '/api/queue/9');
assert.equal(one.status, 200);
const nine = JSON.parse(one.text).row;
assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]);
clean(one.text, repo.base);
assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]);
assert.equal((await get(base, '/api/queue/7')).status, 404);
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 405, path);
}
assert.equal((await get(base, '/api/queue', { headers: { origin: 'https://evil.example' } })).status, 403);
});
test('queue: a refused read fails closed with the store\'s text, redacted', async t => {
const repo = queueRepo(t);
const base = await serve(t, consoleReads({ root: repo.root, env: { ...repo.env, GIT_DIR: join(repo.root, '.git') }, rolesDir: REPO_ROLES }));
const r = await get(base, '/api/queue');
assert.equal(r.status, 503);
const body = JSON.parse(r.text);
assert.equal(body.error, 'queue-refused');
assert.match(body.message, /GIT_DIR/);
clean(r.text, repo.base);
assert.equal((await get(base, '/api/queue/6')).status, 503);
});
test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => {
// Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included.
for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) {
const repo = queueRepo(t);
breakStore(join(repo.root, 'packages/queue/src/store.mjs'));
const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name);
const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
const r = await get(base, '/api/queue');
assert.equal(r.status, 503, name);
assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name);
clean(r.text, repo.base, repo.root);
}
// Only exit 2 forwards the child's text; any other exit is a fixed message.
const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs');
const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); };
await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' });
await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' });
await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' });
});
test('business: names, vars on the allowlist, authority, credential metadata only', async t => {
const s = seeded(t);
const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
const r = await get(base, '/api/business');
assert.equal(r.status, 200, r.text);
clean(r.text, s.base);
const b = JSON.parse(r.text).business;
assert.deepEqual([b.id, b.human, b.arbiters, b.projects], ['acme', 'jason', { delivery: 'pm', technical: 'cto' }, ['stack']]);
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
// An allowlisted var is scrubbed too (Filbert T1).
assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
for (const i of b.instances) {
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
}
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'within');
assert.deepEqual(b.launch, { by: 'pm', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service);
assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']);
assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' });
assert.deepEqual(cred('coder', 'vikunja'), { service: 'vikunja', account: 'bot-acme-coder', role: 'coder', dateKind: 'expires', date: day(3), state: 'expiring' });
assert.equal(cred('reviewer', 'vikunja').state, 'expired');
assert.deepEqual(cred('tracker sync', 'vikunja'), { service: 'vikunja', account: 'bot-acme-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' });
assert.equal(b.credentials.length, 9);
});
test('business: missing or invalid file refuses with the module\'s text, redacted', async t => {
const s = seeded(t, { business: false });
const reads = consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' });
const base = await serve(t, reads);
let r = await get(base, '/api/business');
assert.equal(r.status, 503);
assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file not found: <config>/businesses/acme.json' });
// Invalid JSON whose parse error would quote a secret-looking value.
writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}');
r = await get(base, '/api/business');
assert.equal(r.status, 503);
assert.match(JSON.parse(r.text).message, /^business file is not valid JSON \(<config>\/businesses\/acme\.json\)/);
assert.equal(r.text.includes('s3cret-value-xyz'), false);
clean(r.text, s.base);
// Valid JSON, invalid business: the validator's message names the file.
writeJson(join(s.configDir, 'businesses', 'acme.json'), { ...s.doc, launch: { ...s.doc.launch, by: 'nobody' } });
r = await get(base, '/api/business');
assert.equal(r.status, 503);
assert.match(JSON.parse(r.text).message, /launch\.by names nobody/);
clean(r.text, s.base);
});
test('business without --business: the live bus host, else no-bus-host; no system config: not-configured', async t => {
const s = seeded(t);
let base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES }));
let r = await get(base, '/api/business');
assert.equal(r.status, 503);
assert.equal(JSON.parse(r.text).error, 'no-bus-host');
base = await serve(t, consoleReads({ root: SRC, system: null, configDir: s.configDir, rolesDir: REPO_ROLES }));
r = await get(base, '/api/business');
assert.equal(JSON.parse(r.text).error, 'not-configured');
base = await serve(t, null);
for (const path of ['/api/business', '/api/queue', '/api/queue/1']) assert.equal(JSON.parse((await get(base, path)).text).error, 'not-configured', path);
});
test('settings: release, board, loopback address, notifier binding name only', async t => {
const s = seeded(t);
const repo = queueRepo(t);
let base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
let r = await get(base, '/api/settings');
assert.equal(r.status, 200);
const body = JSON.parse(r.text);
assert.deepEqual({ ...body, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, at: undefined });
clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false });
base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: bare.system, configDir: bare.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
r = await get(base, '/api/settings');
assert.equal(r.status, 200);
const n = JSON.parse(r.text).notifier;
assert.equal(n.refused, 'not-configured');
assert.match(n.message, /^no notifier config at <dataRoot>\/notify\/acme\/notify\.json/);
clean(r.text, bare.base, repo.base);
base = await serve(t, null);
r = await get(base, '/api/settings');
assert.equal(r.status, 200);
assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured');
// --business with no system config: refused in Console's words, not Node's (Filbert N1).
base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
r = await get(base, '/api/settings');
assert.equal(r.status, 200);
assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
});
+30 -4
View File
@@ -131,6 +131,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await b.evaluate('document.querySelector("#mode").value="system";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
// Board, a failed first read: drawn empty and saying why, not left as the loading skeleton. A reload resets window.errors, so check it first.
assert.deepEqual(await b.evaluate('window.errors'), []);
brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /No board data loaded\./);
assert.equal(await count('#sessions [aria-busy]'), 0, 'no skeleton after a failed first read');
assert.equal(await text('#sessions'), '');
assert.equal(await text('#waiting'), '');
assert.deepEqual(await b.evaluate('["waiting-count","seen-count","session-count"].map(id=>document.getElementById(id).textContent)'), ['–', '–', '–']);
assert.equal(await text('#status'), 'Not read: the read failed');
assert.equal(await text('#footer'), 'Board not read: the read failed');
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#projects'), 'Not read: the read failed.');
brd.empty(); await refresh(); await wait('document.querySelector("#error").hidden');
// Board, empty: what would appear and where it comes from.
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
@@ -170,8 +185,10 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
await flat(); await shot('board-refused-400');
// A failed read after the refusal shows the board again, empty: no row, card or count from
// the refused scan, and the footer says what the banner says.
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
await b.evaluate('document.activeElement.blur()');
brd.unavailable(); await refresh();
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
@@ -196,6 +213,13 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
assert.equal(await count('#conv-pick option'), 1);
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
@@ -203,6 +227,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
@@ -234,7 +260,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
}
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 7);
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
@@ -429,7 +455,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
}
// After a refusal the palette lists only the views: no kept decision or task.
const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
const views = ['#/', '#/inbox', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
@@ -452,7 +478,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(${views.length}).map(e=>e.textContent)`), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
// The board page's Inbox read: a refusal there drops the count too.
s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
+266
View File
@@ -0,0 +1,266 @@
// Row 54 (#1543): the Queue, Business and Settings views in a browser. Each view in its five
// states over a stub read: loading, normal, empty, a failed read over kept data, and a refusal
// that shows nothing kept. Queue rows move only with `queue move`, so the page shows that command
// and no button for it. Over the seeded fixture (reads-fixture.mjs), no secret, Discord id or
// temporary path reaches a response, the page or the server's log, and a missing or invalid
// business file shows the business module's own refusal text. Nothing here writes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
import { consoleReads, ReadError } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs';
const PROBE = 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};'
// The bus view's 10s refresh is recorded so a test can fire it early, unless it was cleared.
+ '(()=>{const st=setTimeout,ct=clearTimeout;window.refreshes=new Map();window.setTimeout=(fn,ms,...a)=>{if(ms!==10000||!String(fn).includes("render(false)"))return st(fn,ms,...a);const id=st(()=>{refreshes.delete(id);fn()},ms);refreshes.set(id,fn);return id};window.clearTimeout=id=>{refreshes.delete(id);return ct(id)};window.fireRefresh=()=>{const due=[...refreshes];refreshes.clear();for(const[id,fn]of due){ct(id);fn()}return due.length}})();';
// The three reads over fixed documents, or a ReadError with a chosen code and message.
function stubReads() {
const row = { id: 6, piece: 'Console <b id="q-b">views</b>', state: 'in-progress', owner: 'dewey', reviewers: ['darkwing', 'filbert'], brief: { path: 'docs/plans/brief.md', anchor: 'Row six' }, updatedAt: '2026-10-10T12:00:00Z' };
const docs = {
queue: { rows: [row, { ...row, id: 7, piece: 'Second row', state: 'done' }], notes: [] },
business: {
id: 'acme', human: 'jason', arbiters: { delivery: 'pm' }, projects: ['stack'], vars: { 'tracker.pollSeconds': 60 }, actions: ['role.launch'],
instances: [{ instance: 'pm', definition: 'pm', holder: 'sage', vars: { harness: 'pi' }, authority: { 'role.launch': 'within' } }],
launch: { by: 'pm', instances: ['pm'], max: { opus: 1 } },
credentials: [{ service: 'gitea', account: 'bot-acme', role: 'pm', dateKind: 'expires', date: '2099-01-01', state: 'valid' }],
},
settings: { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' } },
};
let fail = null, gate = null;
const answer = async make => { await gate; if (fail) throw new ReadError(fail.code, fail.message); return { ...structuredClone(make()), at: new Date().toISOString() }; };
const reads = {
queue: () => answer(() => docs.queue),
row: id => answer(() => { const r = docs.queue.rows.find(x => x.id === id); if (!r) throw new ReadError('not-found', `no row ${id}`); return { row: { ...r, review: { rounds: [] } }, notes: [] }; }),
business: () => answer(() => ({ business: docs.business })),
settings: () => answer(() => docs.settings),
};
const hold = () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; };
return { docs, reads, hold, fail: (code, message = `fixture ${code}`) => { fail = code ? { code, message } : null; } };
}
async function page(b, base) {
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<200;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 count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async what => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `sideways scroll at 400px: ${what}`);
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
await b.viewport(400, 900);
await b.navigate(base);
await wait('document.querySelectorAll("#sections a").length===7');
return { wait, text, count, flat, refresh, go };
}
test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => {
const s = stubReads();
// A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing.
const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus });
const b = await browser();
try {
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
// Esc closes the palette at once, but its close event, which puts focus back, is a later task.
// Waiting for that event keeps a late focus from landing on the next step (seen under load).
const closePalette = async () => {
await b.evaluate('window.paletteClosed=new Promise(r=>document.querySelector("#cmdk-dialog").addEventListener("close",()=>setTimeout(r),{once:true}));true');
await b.key('Escape'); await b.evaluate('window.paletteClosed.then(()=>true)');
};
for (const [hash, h1, source, rows, refusals, emptied, emptyText] of [
['#/queue', 'Queue', 'Queue', '#bus-view table.s1-queue tbody tr', ['queue-refused', 'not-configured'], () => { s.docs.queue.rows = []; }, 'The queue has no rows.'],
['#/queue/6', 'Row 6', 'Queue', '#bus-view [aria-labelledby=q-row] dl', ['queue-refused'], null, null],
['#/business', 'Business acme', 'Business', '#bus-view table.s1-roles tbody tr', ['not-configured', 'no-bus-host'], () => { s.docs.business = { ...s.docs.business, instances: [], credentials: [] }; }, 'This business has no role instances.'],
['#/settings', 'Settings', 'Settings', '#bus-view [aria-labelledby=st-notify] dl', ['not-configured'], () => { s.docs.settings = { ...s.docs.settings, notifier: { refused: 'not-configured', message: 'no notifier config at <dataRoot>/notify/acme/notify.json' } }; }, 'No notifier binding to show.'],
]) {
const keep = structuredClone(s.docs);
// Loading: the read is held; the skeleton names the source.
const release = s.hold();
await b.evaluate(`location.hash=${JSON.stringify(hash)}`);
await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")');
assert.equal(await text('#bus-view h1'), 'Reading…', hash);
assert.equal(await text('#bus-view .skel-rows .sr-only'), `Reading the ${source.toLowerCase()}.`, hash);
await flat(`${hash} loading`);
// Normal.
release();
await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`);
const n = await count(rows);
assert.ok(n >= 1, `${hash} shows its rows`);
assert.equal(await count('#bus-view .banner'), 0, hash);
assert.match(await text('#fresh-bus'), new RegExp(`^${source} read \\d\\d:\\d\\d:\\d\\d UTC`), hash);
await flat(`${hash} normal`);
// A refresh of the same page replaces the heading and keeps focus on it (the 10s refresh does the same).
await b.evaluate('document.querySelector("#bus-view h1").focus(); document.querySelector("#bus-view h1").dataset.old = "1"');
await refresh(); await wait('!document.querySelector("#bus-view h1").dataset.old');
assert.equal(await b.evaluate('document.activeElement === document.querySelector("#bus-view h1")'), true, `${hash} keeps heading focus`);
// A failed read over kept data: the same rows under a stale banner, with the read time.
s.fail('read-failed', 'the queue read failed'); await refresh();
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.equal(await count(rows), n, `${hash} keeps its rows`);
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The read did not finish\. \(read-failed\)/, hash);
assert.match(await text('#fresh-bus'), new RegExp(`^${source} read .* stale: the last read failed`), hash);
await flat(`${hash} stale`);
// A refusal: no kept rows anywhere, the code in mono and the module's own text.
for (const code of refusals) {
const said = `fixture ${code}: <config>/businesses/acme.json`;
s.fail(code, said); await refresh();
await wait('!!document.querySelector("#bus-view .banner.ref")');
assert.equal(await text('#bus-view .banner.ref b code'), code, hash);
assert.equal(await text('#bus-view .banner.ref .source'), said, hash);
assert.equal(await count(rows), 0, `${hash} hides kept rows on ${code}`);
assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
assert.equal(await text('#fresh-bus'), `${source} not read: refused`, hash);
// Settings keeps its appearance control through a refusal; it is this browser's own.
assert.equal(await count('#bus-view select[data-mirror]'), hash === '#/settings' ? 2 : 0, hash);
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`${palette}.filter(h=>h.startsWith("#/queue/")).length`), 0, `${hash}: no kept queue row in the palette after ${code}`);
await closePalette();
await flat(`${hash} refused`);
s.fail(null); await refresh();
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
}
// Empty.
if (emptied) {
emptied(); await refresh();
await wait(`document.querySelector("#bus-view .empty b")?.textContent===${JSON.stringify(emptyText)}`);
await flat(`${hash} empty`);
Object.assign(s.docs, keep);
await refresh(); await wait(`document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
}
}
// A row that is not in the queue, and an address that is not a row id.
await go('#/queue/9', 'Not found');
assert.equal(await text('#bus-view .nf b'), 'No row 9 in the queue.');
await go('#/queue/x9', 'Not found');
assert.equal(await text('#bus-view .nf b'), 'x9 is not a queue row id.');
// Queue: state, owner, reviewers and the brief, text only; the move command, never a button.
await go('#/queue', 'Queue');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr:first-child td")].slice(0,5).map(e=>e.textContent)'),
['6 Console <b id="q-b">views</b>', 'in-progress', 'dewey', 'darkwing, filbert', 'docs/plans/brief.md#Row six']);
assert.equal(await count('#bus-view #q-b'), 0);
// Fix to HEAD behaviour: a navigation clears the pending refresh. Fired during the
// navigation's read, the refresh would replace it and focus would fall to <body>.
assert.equal(await b.evaluate('refreshes.size'), 1, 'a refresh is due on #/queue');
const release = s.hold();
await b.evaluate('location.hash="#/business"');
await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")');
assert.equal(await b.evaluate('fireRefresh()'), 0, 'the navigation cleared the due refresh');
release();
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Business") && !document.querySelector("#bus-view [aria-busy]")');
assert.equal(await b.evaluate('document.activeElement === document.querySelector("#bus-view h1")'), true, 'the navigation keeps heading focus');
await go('#/queue', 'Queue');
assert.match(await text('#bus-view .page-head'), /Rows move only with scripts\/mosaic queue move in a terminal\. Console has no button for it\./);
await go('#/queue?state=done', 'Queue');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr")].map(e=>e.cells[0].textContent)'), ['7 Second row']);
await go('#/queue/6', 'Row 6');
assert.equal(await text('#bus-view .s1-cmd code'), 'scripts/mosaic queue move 6 <state> --op <op> --by <seat>');
await go('#/business', 'Business acme');
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery)');
for (const hash of ['#/queue', '#/queue/6', '#/business', '#/settings']) {
await go(hash, { '#/queue': 'Queue', '#/queue/6': 'Row 6', '#/business': 'Business', '#/settings': 'Settings' }[hash]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view button, #bus-view form, #bus-view input")].filter(e=>!e.matches("button[data-copy], button[data-retry]")).length'), 0, `${hash}: no control but Copy and Read again`);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view button")].some(e=>/move|resolve|approve|save|edit/i.test(e.textContent))'), false, hash);
}
// The palette lists queue rows from the last queue read, as text, and still does once its
// inbox and tasks reads have answered. Over a server with no bus they were refusals that
// forgot the queue rows, and the check passed only when it ran before they returned.
const asked = await b.evaluate('window.requests.length');
await b.key('k', 'KeyK', 2);
await wait(`window.requests.slice(${asked}).filter(([,u])=>/\\/api\\/bus\\/(inbox|tasks)$/.test(u)).length===2 && window.bodies.length===window.requests.length`);
await wait(`${palette}.includes("#/queue/7")`);
assert.equal(await count('#cmdk-list #q-b'), 0);
await closePalette();
// Settings: appearance is the one control, mirrored with the command bar and kept in this browser.
await go('#/settings', 'Settings');
assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm');
assert.match(await text('#bus-view [aria-labelledby=st-about]'), /Release0\.0\.99/);
assert.match(await text('#bus-view [aria-labelledby=st-about]'), new RegExp(`Addresshttp://127\\.0\\.0\\.1:${web.address().port}/`));
await b.evaluate('{const m=document.querySelector("#bus-view select[data-mirror=mode]");m.value="dark";m.dispatchEvent(new Event("change",{bubbles:true}))}');
await wait('document.querySelector("#mode").value==="dark"');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
// The 10 s refresh redraws Settings and keeps focus on the mirror select (Darkwing D29).
await b.evaluate('document.querySelector("#bus-view select[data-mirror=mode]").focus(); document.querySelector("#bus-view h1").dataset.old = "1"');
assert.equal(await b.evaluate('fireRefresh()'), 1, 'a refresh is due on #/settings');
await wait('!document.querySelector("#bus-view h1").dataset.old && !document.querySelector("#bus-view [aria-busy]")');
assert.equal(await b.evaluate('document.activeElement?.matches("#bus-view select[data-mirror=mode]") ?? false'), true, 'the refresh keeps focus on the mirror select');
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
} finally { await b.close(); await close(web); }
});
test('seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module\'s words', { timeout: 180000 }, async t => {
const s = seeded(t), repo = queueRepo(t);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) });
const b = await browser();
try {
const { wait, text, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
const seen = async () => clean(await b.evaluate('document.documentElement.outerHTML + "\\n" + window.bodies.join("\\n")'), s.base, repo.base, repo.root, tmpdir());
await go('#/queue', 'Queue'); await seen();
await go('#/queue/6', 'Row 6');
assert.equal(await text('#bus-view dd.s1-q'), NOTE_SHOWN);
await seen();
// After as the store keeps it, {id, when}: a link to the row and its condition (Filbert B1).
const kv = (dt) => b.evaluate(`[...document.querySelectorAll("#bus-view [aria-labelledby=q-row] dt")].find(e=>e.textContent===${JSON.stringify(dt)})?.nextElementSibling?.innerHTML ?? null`);
await go('#/queue/9', 'Row 9');
assert.equal(await kv('After'), '<a href="#/queue/6">6</a> settled');
assert.match(await kv('Required'), /^yes( since |$)/);
await go('#/queue/11', 'Row 11');
assert.equal(await kv('After'), '<a href="#/queue/9">9</a> done');
assert.equal(await kv('Required'), null);
await seen();
await go('#/business', 'Business acme');
assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/);
// The business file's arbiters object, as loadBusiness gives it (Darkwing 27186).
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery), cto (technical)');
await seen();
await go('#/settings', 'Settings');
assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm');
await seen();
assert.equal(await b.evaluate('document.body.innerText.includes("jason-dm.json")'), false);
// A missing business file, then one whose parse error would quote a value.
rmSync(join(s.configDir, 'businesses', 'acme.json'));
await go('#/business', 'Nothing to read');
assert.equal(await text('#bus-view .banner.ref b code'), 'not-configured');
assert.equal(await text('#bus-view .banner.ref .source'), 'business file not found: <config>/businesses/acme.json');
writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}');
await b.evaluate('document.querySelector("#refresh").click()');
await wait('/not valid JSON/.test(document.querySelector("#bus-view .banner.ref .source")?.textContent)');
assert.match(await text('#bus-view .banner.ref .source'), /^business file is not valid JSON \(<config>\/businesses\/acme\.json\)/);
assert.equal(await b.evaluate('document.documentElement.outerHTML.includes("s3cret-value-xyz") || window.bodies.some(x=>x.includes("s3cret-value-xyz"))'), false);
await seen();
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
} finally { await b.close(); await close(web); }
});
test('the server log names no config path when the system config refuses', { timeout: 30000 }, async t => {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'mosaic-webui-log-')));
t.after(() => rmSync(base, { recursive: true, force: true }));
for (const [name, setup] of [['missing', () => {}], ['invalid', () => { mkdirSync(join(base, 'cfg'), { recursive: true }); writeFileSync(join(base, 'cfg', 'config.json'), '{"dataRoot": s3cret-value-xyz}'); }]]) {
setup();
const child = spawn(process.execPath, [join(SRC, 'cli.mjs'), 'serve', '--port', '0'], { env: { ...process.env, MOSAIC_CONFIG: join(base, 'cfg', 'config.json') } });
let out = '';
child.stdout.on('data', c => { out += c; }); child.stderr.on('data', c => { out += c; });
const exited = new Promise(r => child.once('exit', r));
for (let i = 0; i < 200 && !out.includes('Ctrl-C') && child.exitCode === null; i++) await new Promise(r => setTimeout(r, 50));
child.kill('SIGTERM'); await exited;
assert.match(out, /Bus: not configured \(/, `${name}: ${out}`);
assert.equal(out.includes(base), false, `${name}: the log names the temporary directory: ${out}`);
assert.equal(out.includes('s3cret-value-xyz'), false, `${name}: the log quotes the config`);
}
});