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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user