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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <business></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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user