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]>
211 lines
12 KiB
JavaScript
211 lines
12 KiB
JavaScript
// 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);
|
|
},
|
|
};
|
|
}
|