Files
stack/packages/webui/src/reads.mjs
T
jason.woltjeandClaude Opus 5.5 6c42344d9d tasks, webui: the task field-writer table as data (#1550)
Row 61. packages/tasks/fields.json holds the slice 1 field table in two
layers (decision 113): each broker task verb's writer check, and each
field's writing verbs and Vikunja calls. packages/tasks/src/fields.mjs
checks the shape as code and loads it frozen. The tasks test reads the
writer layer from the table and authority from roles/*.json and drives
every task verb against the fake Vikunja. The Console serves the table
at GET /api/task-fields, and the task page shows a "Who may write"
section. The slice 1 doc's field table points to the file.

Darkwing's round 1, candidate manifest 33b96986, base 4f8ac1e7. Approved
by Dewey (27776) and Filbert (27785). Sage's gate green 18:57:31Z to
19:01:58Z.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:02:56 -05:00

221 lines
13 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';
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, '<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);
},
};
}