--- a/packages/queue/README.md +++ b/packages/queue/README.md @@ -286,6 +286,11 @@ 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 --- a/packages/queue/src/store.mjs +++ b/packages/queue/src/store.mjs @@ -811,6 +811,13 @@ 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); --- a/packages/queue/tests/write.test.mjs +++ b/packages/queue/tests/write.test.mjs @@ -334,6 +334,24 @@ 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")); }; --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -40,7 +40,8 @@ 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 @@ -90,9 +91,10 @@ - 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 +120,8 @@ (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, @@ -133,6 +135,42 @@ 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/` 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, arbiters, 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 ``, the +data root ``, any other absolute path ``, a 17 to 20 digit run +``, and a JSON parse error's quote of the file is dropped. The startup +log goes through the same redactor. + ## Data and boundaries GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST @@ -161,6 +199,20 @@ (`~/.config/mosaic-dev/config.json`); without it the board still serves and the bus routes answer `not-configured`. +GET `/api/queue`, `/api/queue/`, `/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 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 @@ -178,6 +230,7 @@ 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` @@ -220,6 +273,21 @@ 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 a token path and an id, and a business, notifier +config and Discord binding that hold token files, an environment variable +name, and guild, channel, bot and user ids. `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. +`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; then, over the seeded fixture, 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. --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -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 @@ } // 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) { --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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 ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents']]; + const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${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 `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; @@ -316,8 +335,97 @@

    Board seats stay on the control board. This view is about roles.

    `; } + // ---------------------------------------------------------------- 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 = 'none'; + 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) ? `${esc(id)}` : txt(id); + const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none; + 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 ? `${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}` : none; + const storeNotes = notes => Array.isArray(notes) && notes.length ? `

    From the store ${notes.length}

      ${notes.map(n => `
    • ${txt(n)}
    • `).join('')}
    ` : ''; + const moveHelp = 'Rows move only with scripts/mosaic queue move in a terminal. Console has no button for it.'; + function queueRow(r) { + return `${esc(r.id)} ${txt(r.piece)}${qState(r.state)}${txt(r.owner) || none}${names(r.reviewers)}${briefRef(r.brief)}${when(r.updatedAt)}`; + } + 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 = ``; + const table = `
    ${['Row', 'State', 'Owner', 'Reviewers', 'Brief', 'Updated'].map(h => ``).join('')}${shown.map(queueRow).join('')}
    ${h}
    `; + const body = !rows.length ? empty('The queue has no rows.', 'A row shows here after scripts/mosaic queue add records it in docs/plans/queue.json.') + : 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 `
  • Round ${txt(r.n)} requested by ${txt(r.by)} ${when(r.at)}${r.issue ? ` on #${txt(r.issue)}` : ''}${r.candidate ? ` · candidate ${txt(r.candidate.kind)} ${txt(String(r.candidate.digest ?? '').slice(0, 12))}` : ''}

    + ${receipts.length ? `
      ${receipts.map(x => `
    • ${txt(x.reviewer)}: ${badge(x.verdict ?? 'no verdict', x.verdict === 'approve' ? 'ok' : x.verdict ? 'attn' : 'muted')} ${when(x.at)}${x.comment ? ` comment ${txt(x.comment)}` : ''}
    • `).join('')}
    ` : '

    No verdict yet.

    '}
  • `; + }; + 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} --op --by `; + return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` was ${txt(r.previousState)}` : ''} ${readLine()}`)} + ${storeNotes(doc.notes)} +

    Row

    Owner
    ${txt(r.owner) || none}
    Reviewers
    ${names(r.reviewers)}
    Gate
    ${txt(r.gateOwner) || none}
    Issues
    ${issueRefs(r.issues)}
    Closes
    ${issueRefs(r.closes)}
    Brief
    ${briefRef(r.brief)}${r.brief?.blob ? ` blob ${txt(String(r.brief.blob).slice(0, 12))}` : ''}
    After
    ${Array.isArray(r.after) && r.after.length ? r.after.map(qLink).join(', ') : none}
    Claim
    ${r.claim ? `${txt(r.claim.seat)} ${txt(r.claim.op)}` : none}
    ${r.required ? `
    Required
    ${txt(r.required)}${r.requiredSince ? ` since ${when(r.requiredSince)}` : ''}
    ` : ''}${r.blockedReason ? `
    Blocked
    ${txt(r.blockedReason)}
    ` : ''}${r.note ? `
    Note
    ${txt(r.note)}
    ` : ''}
    Created
    ${when(r.createdAt)}
    Updated
    ${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}
    +

    Review ${rounds.length}

    ${rounds.length ? `
      ${rounds.map(round).join('')}
    ` : '

    No review round yet.

    '}
    +

    Moving this row

    ${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.

    +
    ${txt(cmd)}

    +

    Back to the queue

    `; + } + + 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 ? `
    ${e.map(([k, v]) => `
    ${txt(k)}
    ${txt(Array.isArray(v) ? v.join(', ') : typeof v === 'object' && v !== null ? JSON.stringify(v) : v)}
    `).join('')}
    ` : `

    ${label}

    `; + }; + 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 => `${txt(i.instance)}${txt(i.definition)}${txt(i.holder) || none}${txt(i.vars?.harness) || none}${i.vars?.model ? `${txt(i.vars.model)}` : ''}`).join(''); + const matrix = authKnown ? `
    ${instances.map(i => ``).join('')}${actions.map(a => `${instances.map(i => { const c = i.authority?.[a]; return ``; }).join('')}`).join('')}
    Action${txt(i.instance)}
    ${txt(a)}${c ? `${txt(c)}` : 'refused'}
    ` + : '

    Authority comes from the system config with the business file. This Console could not read the system config, so it shows none.

    '; + const refused = instances.filter(i => i.refused).map(i => `
  • ${txt(i.instance)}: ${txt(i.refused)}
  • `).join(''); + const credRows = creds.map(c => `${txt(c.service)}${txt(c.account) || 'not named in the business file'}${txt(c.role)}${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} ${badge(c.state, CRED[c.state] || 'muted')}`).join(''); + return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)} +

    Business

    Human
    ${txt(b.human) || none}
    Arbiters
    ${names(b.arbiters)}
    Projects
    ${names(b.projects)}
    Launcher
    ${txt(launch.by) || none}
    May launch
    ${names(launch.instances)}
    At most
    ${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}
    +

    Vars

    ${kvRows(b.vars, 'No vars Console shows are set.')}
    +

    Role instances ${instances.length}

    ${instances.length ? `
    ${['Instance', 'Definition', 'Holder', 'Harness'].map(h => ``).join('')}${roleRows}
    ${h}
    ` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}
    + ${instances.length ? `

    Authority

    Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.

    ${matrix}${refused ? `
      ${refused}
    ` : ''}
    ` : ''} +

    Credentials ${creds.length}

    Metadata only: service, account, role and date. Console never reads a token, a token file or a variable.

    ${creds.length ? `
    ${['Service', 'Account', 'Role', 'Date', 'State'].map(h => ``).join('')}${credRows}
    ${h}
    ` : empty('No credentials are named.', 'A credential shows here when a role instance or tracker sync names one.')}
    `; + } + + // 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) => ``; + return `

    Appearance

    Stored in this browser only. The same control is in the command bar.

    ${pick('palette', 'Palette', p)}${pick('mode', 'Appearance', m)}
    `; + } + async function settingsView(get) { + const s = await get.api('settings'), n = s.notifier || {}; + const notify = typeof n.binding === 'string' + ? `
    Binding
    ${txt(n.binding)}

    The binding name only. Its channels, server and user ids stay in the binding file.

    ` + : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); + return `${head('Settings', `${readLine()} Only appearance changes here.`)}${appearance()} +

    Notifications

    ${notify}
    +

    About

    Address
    ${txt(s.address) || 'unknown'}
    Release
    ${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}
    Business
    ${txt(s.business) || none}
    Board
    ${txt(s.board) || 'unknown'}
    `; + } + // Trail - const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']]; + const GROUPS =[['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']]; function trailList(rows, filter) { const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter); if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.'); @@ -348,11 +456,23 @@ // Not found and states function notFound(why) { return `${head('Not found')}
    ${why || 'No page at this address.'}

    Nothing is at ${txt(location.hash || '#/')}. Go to the Board or press Ctrl K to search.

    `; } - const loadingView = () => `${head('Reading…')}
    Reading from the bus.
    `; + const loadingView = (source = null) => `${head('Reading…')}
    ${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}
    `; const retry = '
    '; // Why a read failed, in words, with the bus code as given. Never a path or a guess. function why(err) { const code = `${txt(err.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 --business, so it knows no business. Start one with mosaic bus start <business>.', + '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 ? `
    ${txt(err.message)}` : ''}`; + } 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 +486,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))}${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))}${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 => ``; // 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 not read: refused' : state === 'failed' ? 'Bus not read: the read failed' - : `Bus read ${esc(stamp(readAt))}${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · stale: the last read failed' : ''}`; + // The queue, business and settings pages name their own source. + function freshBus(state, source = 'Bus') { + $('fresh-bus').innerHTML = state === 'refused' ? `${esc(source)} not read: refused` : state === 'failed' ? `${esc(source)} not read: the read failed` + : `${esc(source)} read ${esc(stamp(readAt))}${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · stale: the last read failed' : ''}`; } + 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 +514,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,15 +529,16 @@ 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) { const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === ''; @@ -427,18 +554,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 +616,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 +662,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 --- a/packages/webui/src/queue-read.mjs +++ b/packages/webui/src/queue-read.mjs @@ -0,0 +1,16 @@ +// 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. + +import { rows } from '../../queue/src/store.mjs'; +import { QueueError } from '../../queue/src/errors.mjs'; + +try { + const r = rows({}); + process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`); +} catch (err) { + process.stderr.write(`${err instanceof QueueError ? err.message : 'the queue read failed'}\n`); + process.exitCode = err instanceof QueueError && Number.isInteger(err.code) && err.code > 0 ? err.code : 1; +} --- a/packages/webui/src/reads.mjs +++ b/packages/webui/src/reads.mjs @@ -0,0 +1,203 @@ +// 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 and , any other absolute +// path , and a 17 to 20 digit run (a Discord id) . + +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, ''], [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').replace(/(?'); + }; +} +// 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. Anything + // else failed to run; 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', said || `the queue read exited ${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/. +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 }); + 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); + }, + }; +} --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -3,6 +3,7 @@ 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 @@ 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 @@ 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 }); --- a/packages/webui/tests/reads-fixture.mjs +++ b/packages/webui/tests/reads-fixture.mjs @@ -0,0 +1,55 @@ +// 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. A queue row's +// note names a token path and an id. None may reach a response, the page or +// a log, nor may the temporary directory. +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); + +// 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: `token at /srv/secret/x.token for ${SNOWFLAKES[4]}` } : 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); + 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']) assert.equal(text.includes(s), false, `response carries ${s}`); +} --- a/packages/webui/tests/reads.test.mjs +++ b/packages/webui/tests/reads.test.mjs @@ -0,0 +1,161 @@ +// 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 { join } from 'node:path'; +import { startServer } from '../src/serve.mjs'; +import { consoleReads, credentialState, redactor } from '../src/reads.mjs'; +import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; +import { SRC, SNOWFLAKES, 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: /businesses/a.json'); + assert.equal(r('no notifier config at /home/u/.mosaic-dev/notify/a/notify.json; write'), 'no notifier config at /notify/a/notify.json; write'); + assert.equal(r('file /run/x.token, see https://git.example/a'), 'file , see https://git.example/a'); + assert.equal(r('/home/u/.config/mosaic-devX/y'), ''); + assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user ok'); + 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 \(\/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, 'token at for '); + 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.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('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' }); + 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: /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 \(\/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 \/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'); +}); --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -193,7 +193,7 @@ 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]); @@ -388,7 +388,7 @@ } // 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/); @@ -411,7 +411,7 @@ s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: 'Bold 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)'), ['Push?', '#7 Bold 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)`), ['Push?', '#7 Bold 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'); --- a/packages/webui/tests/views.test.mjs +++ b/packages/webui/tests/views.test.mjs @@ -0,0 +1,221 @@ +// 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, 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 three reads over fixed documents, or a ReadError with a chosen code and message. +function stubReads() { + const row = { id: 6, piece: 'Console views', 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(); + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads }); + 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)'; + 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 /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}: /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 b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); + 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 views', 'in-progress', 'dewey', 'darkwing, filbert', 'docs/plans/brief.md#Row six']); + assert.equal(await count('#bus-view #q-b'), 0); + 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 --op --by '); + 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. + await b.key('k', 'KeyK', 2); await wait(`${palette}.includes("#/queue/7")`); + assert.equal(await count('#cmdk-list #q-b'), 0); + await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); + + // 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'); + + 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'), 'token at for '); + await seen(); + await go('#/business', 'Business acme'); + assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/); + 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: /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 \(\/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`); + } +});