// 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 // or ~/ path , and a 17 to 20 digit run (a Discord 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'; import { loadFields, FieldTableError } from '../../tasks/src/fields.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; // Row 61 (#1550): the task field table, packages/tasks/fields.json, read and checked on each // request. It is repository data, so it needs no business configured. A table that fails its // check is refused, never shown in part. Its messages name the file, never a path. export function fieldTable(file) { try { return loadFields(file); } catch (err) { throw new ReadError('read-failed', `the task field table did not load: ${err instanceof FieldTableError ? err.message : 'unreadable'}`); } } 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 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/. 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); }, }; }