Files
stack/packages/webui/src/public/bus.js
T
jason.woltjeandClaude Opus 5.5 a85120070b bus: agent state per role, and the Console shows it (#1548)
Row 59 round 2, owner Filbert, candidate manifest 9acfac19 on 3d298920.
Approved by Darkwing (27591) and Dewey (27595); Sage's gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:13:43 -05:00

696 lines
66 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// The CHAT-02 rules: C0 controls show as control pictures, C1 and bidi controls as
// [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f؜‎‏‪-‮⁦-⁩]/g;
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const txt = v => esc(inert(v)); // every agent-, tracker- or bus-authored string goes through this
const announce = text => { $('announce').textContent = text; };
const enc = encodeURIComponent;
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/; // the broker's identifier rule
const TASK = /^vikunja:(\d+)\/(\d+)$/;
const CLOSE = ['resolved', 'withdrawn', 'expired'];
// ---------------------------------------------------------------- reads
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
// can show what was read before, labelled as such.
const last = new Map();
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
async function fetchRead(verb, subject) {
let res, body;
try {
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
body = await res.json();
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
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 = [];
const get = async (verb, subject) => {
const k = subject === undefined ? verb : `${verb}:${subject}`;
let r;
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
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;
}
const of = (rows, table) => rows.filter(r => r.table === table);
// One decision from its trail. An open human decision's inbox row carries the
// authorization context; otherwise it comes from the trail's decision.raise event,
// the rule the broker's own decision view uses.
async function decision(get, id, row = null) {
const tr = await get('trail', id), d = of(tr, 'decisions').find(x => x.id === id);
if (!d) return null;
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body?.operation === 'decision.raise' && e.body?.decision === id);
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
return {
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
seen: ev.filter(x => x.op === 'seen'), closed, dm: dm ? { at: dm.at } : null, supersedes: d.supersedes, trail: tr,
};
}
// Earlier versions through `supersedes`, newest first, one trail read each, at most ten.
async function chain(get, d) {
const out = [];
for (let x = d, n = 0; x?.supersedes && n < 10; n++) { x = await decision(get, x.supersedes); if (x) out.push(x); }
return out;
}
// A task from its task_current row; the trail adds what only events carry.
function task(row, tr = null) {
const ref = row.task_ref, f = row.fields || {}, [, project, id] = ref.match(TASK) || [, null, null];
const evs = tr ? of(tr, 'events').filter(e => e.subject === ref) : [], snaps = tr ? of(tr, 'task_snapshots') : [];
const shape = f.gone ? snaps.filter(s => !s.fields?.gone).at(-1)?.fields || {} : f;
const created = evs.find(e => e.kind === 'task.created');
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
// task_current already skips stale reads, so a current row from a poll is a change the
// stack didn't make: to a task the stack wrote ('changed') or to one it never wrote ('foreign').
const external = !f.gone && row.source === 'poll' ? { kind: tr ? (snaps.some(s => s.source === 'self') ? 'changed' : 'foreign') : null, via: row.via, readAt: row.read_at, changed: Array.isArray(ext?.body?.changed) ? ext.body.changed : [], comments: ext?.body?.comments || 0 } : null;
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
return {
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
// The bus 'tasks' row carries both (row 58). A tombstone's row has no priority, so the detail
// view falls back to the last snapshot that read the task.
requirement: row.requirement ?? created?.body?.requirement ?? null, request: created?.body?.request ?? null,
due: shape.due_date ?? null, priority: row.priority ?? shape.priority ?? null, percent: shape.percent_done ?? 0,
changedAt: row.at, seq: row.seq, external,
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
decisions: tr ? of(tr, 'decisions').filter(d => d.task_ref === ref) : [],
// Rows after the current one are reads task_current skipped (lead decision 59).
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, fields: s.fields || {} })),
};
}
// ---------------------------------------------------------------- trail lines
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
const bucketText = f => f?.gone ? `gone (${f.gone})` : `bucket ${f?.bucket}`;
const short = id => String(id ?? '').slice(0, 8);
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
function line(r, ctx) {
const out = { at: r.at, actor: null, by: 'broker', decision: null, note: null };
if (r.table === 'events') {
const b = r.body && typeof r.body === 'object' ? r.body : {};
out.kind = r.kind; out.decision = typeof b.decision === 'string' ? b.decision : null;
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
const map = {
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run}${b.reason ? ` (${b.reason}${b.exit !== undefined ? `, exit ${b.exit}` : ''})` : ''}`,
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `The human chose “${b.choice}” from the CLI` : `The human ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
: b.kind === 'instruction' ? 'Request from the human, recorded from the CLI' : b.kind === 'admin' ? 'Admin command from the human, CLI' : 'Input from the human',
'task.created': () => `Created for ${b.requirement}`,
'task.assigned': () => `Assigned to ${b.role}`,
'task.state': () => `Moved ${b.previous ? `from ${b.previous} ` : ''}to ${b.state}${b.percent_done !== undefined ? `, ${b.percent_done}% done` : ''}${b.comment ? ', with a comment in Vikunja' : ''}`,
'task.closed': () => `Closed with verdict ${b.verdict}`,
'task.changed.external': () => {
const changed = Array.isArray(b.changed) ? b.changed : [], comments = b.comments ? plural(b.comments, 'new comment') : '';
if (b.previous === null) return 'Read from Vikunja for the first time: the stack did not create it';
return changed.length ? `Changed in Vikunja: ${changed.join(', ')}${comments ? `, and ${comments}` : ''}` : comments ? `${comments[0].toUpperCase()}${comments.slice(1)} in Vikunja` : 'Read from Vikunja';
},
'task.conflict': () => `Write refused (${b.verb}): Vikunja changed after the stack read it (expected ${short(b.expected)}, found ${short(b.actual)})`,
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
'review.requested': () => 'Review requested',
'review.verdict': () => `Review verdict: ${b.verdict ?? 'not recorded'}`,
'action.refused': () => `Refused: ${b.code}`,
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}`,
'launch.revoked': () => 'Launching revoked from the CLI',
'launch.restored': () => 'Launching restored from the CLI',
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires ?? 'soon'}`,
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
};
out.text = (map[r.kind] || (() => r.kind))();
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
if (r.kind === 'human.input' && b.kind === 'answer') out.groupAs = 'decision'; // an answer, not a request
} else if (r.table === 'messages') {
out.kind = `message.${String(r.class).toLowerCase()}`; out.decision = r.decision;
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
out.text = `${r.from_role === 'human' ? 'The human' : r.from_role} to ${r.to_role}: ${r.body}`;
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
} else if (r.table === 'deliveries') {
const m = ctx.messages[r.message];
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
} else if (r.table === 'decisions') {
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
} else if (r.table === 'decision_events') {
// The human answers from the CLI (via cli); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
} else if (r.table === 'task_snapshots') {
out.kind = `snapshot.${r.source}`;
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
out.text = r.source === 'self' ? `Written: ${bucketText(r.fields)}` : `Read from Vikunja (${r.via} read sent ${String(r.read_at).slice(11, 19)}): ${bucketText(r.fields)}`;
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
} else if (r.table === 'role_claims') {
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
} else { out.kind = String(r.table); out.text = 'A row this page does not know how to show'; }
out.group = out.groupAs || group(out.kind);
return out;
}
function lines(tr, current = null) {
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body?.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
return tr.map(r => line(r, ctx));
}
// ---------------------------------------------------------------- render helpers
function ago(iso) {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return String(iso ?? 'unknown');
const s = Math.max(0, Math.round((Date.now() - t) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
}
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
// Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
let readAt = null;
const readNote = () => `Read from the bus ${when(readAt)}.`;
// Lead decision 63: what no Q1 read returns carries this label where it would show.
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
// Which choice authorizes which action on which target, as the broker recorded it at
// decision.raise. Never inferred from the recommendation or an option's position.
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
const first = q => String(q ?? '').split('\n')[0];
const due = d => d ? `<time datetime="${esc(d)}" title="${esc(d)}">${esc(String(d).slice(0, 10))}</time>` : '<span class="muted">none</span>';
const user = u => `user ${esc(u)}`;
// ---------------------------------------------------------------- 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'], ['/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>`;
}).join('');
}
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
async function inboxView(get) {
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
}
async function decisionView(get, id) {
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
if (!d) return notFound(`No decision with id ${txt(id)}.`);
const c = d.closed;
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
// A command is offered only for an open decision routed to the human, and only when
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
const answerable = !c && d.routeTo === 'human';
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
? `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${txt(o.key)}">Copy</button></div>`
: '<p class="small muted">This option has no command here: its key is not a plain identifier.</p>';
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${copy}</li>`;
}).join('')}</ol>`;
const earlier = await chain(get, d);
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
}
// Tasks
function markers(t) {
const out = [];
// The list has no trail, so it can only say the current state came from a Vikunja read;
// the task page tells a change to a task the stack wrote from one it never wrote.
if (t.external) out.push(t.external.kind === null ? badge(`last read from Vikunja (${t.external.via})`, 'attn') : t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.changed.length ? `: ${t.external.changed.join(', ')}` : ''}`, 'attn'));
if (t.conflicts.length) out.push(badge(plural(t.conflicts.length, 'refused write'), 'danger'));
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
return out.join(' ');
}
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
const priority = p => p === null ? '<span class="muted">none</span>' : esc(PRIORITY[p] || p);
function taskRow(t) {
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.requirement ? txt(t.requirement) : '<span class="muted">none</span>'}</td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${priority(t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
}
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
function freshness(rows) {
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
}
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
async function tasksView(get) {
const rows = await get('tasks'), ts = rows.map(r => task(r));
if (!ts.length) return `${head('Tasks', freshness(rows))}${empty('No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.')}`;
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
const groups = buckets.map(b => {
const g = open.filter(t => t.bucket === b).sort(byChanged), id = `b-bucket-${esc(b)}`;
return `<section aria-labelledby="${id}"><h2 id="${id}">Bucket ${esc(b)} <span class="count">${g.length}</span></h2>${taskTable(g, `Bucket ${b} tasks`)}</section>`;
}).join('');
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`;
}
async function taskView(get, ref) {
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
const tr = await get('trail', ref), t = task(row, tr);
const ext = t.external ? `<dt>Vikunja</dt><dd>${t.external.kind === 'foreign' ? 'Not created by the stack; first read' : 'Changed in Vikunja after the stack wrote it'}${t.external.changed.length ? `: ${txt(t.external.changed.join(', '))}` : ''}${t.external.comments ? `, ${esc(plural(t.external.comments, 'new comment'))}` : ''} <span class="source">${txt(t.external.via)} read sent ${clock(t.external.readAt)}</span></dd>` : '';
const conflicts = t.conflicts.length ? `<dt>Refused writes</dt><dd>${t.conflicts.map(c => `${txt(c.verb)}: Vikunja changed after the stack read it (expected ${txt(short(c.expected))}, found ${txt(short(c.actual))}) ${when(c.at)}`).join('<br>')}</dd>` : '';
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
return `${head(title(t), `${sub} ${markers(t)}`)}
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${priority(t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
}
// Agents
// Row 59 (#1548): the state mark is the control board's (shell.css), glyph and word. The
// broker derives the state from its own records: working while the holder has messages it
// received and hasn't answered, waiting while a decision the role raised waits for the
// human, otherwise idle.
const stateMark = state => `<span class="badge st st-${esc(state || 'unknown')} badge-${{ working: 'ok', waiting: 'attn' }[state] || 'muted'}">${esc(state || 'unknown')}</span>`;
function attention(c) {
const asks = Array.isArray(c.inputNeeded) ? c.inputNeeded : [];
if (!asks.length) return none;
const d = asks[0];
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
}
async function agentsView(get) {
const claims = await get('agents');
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote())}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
<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) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
}
async function trailView(get, kind, ref, q) {
let rows;
if (kind === 'task') {
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
rows = lines(await get('trail', ref), row.seq);
} else {
const d = await decision(get, ref);
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
// A decision's trail with its earlier versions, merged in the broker's order.
const all = new Map();
for (const x of [d, ...await chain(get, d)]) for (const r of x.trail) all.set(`${r.table}:${r.seq}`, r);
rows = lines([...all.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.table).localeCompare(String(b.table)) || a.seq - b.seq));
}
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
const base = `#/trail/${kind}/${enc(ref)}`;
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
return `${head(name, `${readNote()} Oldest first. ${back}`)}
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
${trailList(rows, filter)}`;
}
// 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 = (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 &lt;business&gt;</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.',
'unknown-business': 'The bus does not know this business.',
'read-only': 'The bus refused the read.',
'not-configured': 'This Console has no bus configured: the system config could not be read when it started. The control board still works.',
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>. Start one with <code>mosaic bus start &lt;business&gt;</code>.',
'outcome-unknown': 'The bus did not answer in time.',
'invalid-request': 'The bus refused this address as a request.',
unreachable: 'The Console server did not answer.',
}[err.code] || 'The bus answered with something Console cannot use.';
return `${say} (${code})`;
}
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', '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.' : 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'; };
// 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.
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
function view(get, path, q) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
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();
}
let gen = 0, shown = null, timer;
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
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'), 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],[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);
$('bus-view').hidden = board;
sections(path);
if (board) {
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
if (g === gen) schedule();
return;
}
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) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (g !== gen) return;
freshBus(state, SOURCES[path.split('/')[1]]);
const k = !navigated && shown === route ? keep() : null;
$('bus-view').innerHTML = html; shown = route;
sections(path);
const h1 = $('bus-view').querySelector('h1');
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
if (k) restore(k);
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
schedule();
}
// ---------------------------------------------------------------- events
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
// unavailable, the command text is selected so the person can copy it themselves.
document.addEventListener('click', async e => {
// The skip link moves focus only. Its #main would otherwise reach the hash router.
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
const b = e.target.closest('#bus-view [data-copy]');
if (b) {
const text = b.dataset.copy, out = $('copy-status');
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; toast(out.textContent); }
catch {
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
toast(out.textContent, true);
}
return;
}
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
const item = e.target.closest('#cmdk-list [data-href]');
if (item) { pkGo(item.dataset.href); return; }
if (e.target.closest('#cmdk')) { pkOpen(); return; }
if (e.target.closest('#bus-view [data-retry]')) render(false);
});
// ---------------------------------------------------------------- toast
// One line in a polite live region. Success goes after 2.6 s; an error stays until dismissed.
let toastTimer;
function toast(text, error = false) {
const t = $('toast'); clearTimeout(toastTimer);
if (text === null) { t.className = 'toast'; t.textContent = ''; return; }
t.className = `toast on${error ? ' err' : ''}`;
t.innerHTML = `<span>${esc(text)}</span>${error ? '<button type="button" data-dismiss>Dismiss</button>' : ''}`;
if (!error) toastTimer = setTimeout(() => toast(null), 2600);
}
// ---------------------------------------------------------------- command palette
// 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'], ['#/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' }; });
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();
pkShown = pkAll.filter(i => !q || `${i.label} ${i.group}`.toLowerCase().includes(q)).slice(0, 50);
pkAt = Math.min(pkAt, Math.max(0, pkShown.length - 1));
$('cmdk-list').innerHTML = pkShown.length ? pkShown.map((i, n) => `<li role="option" id="cmdk-opt-${n}" data-href="${esc(i.href)}" aria-selected="${n === pkAt}"><span>${txt(i.label)}</span><span class="g">${txt(i.group)}</span></li>`).join('')
: '<li class="pk-none" role="option" aria-disabled="true" aria-selected="false">No view, decision or task matches.</li>';
if (pkShown.length) { $('cmdk-input').setAttribute('aria-activedescendant', `cmdk-opt-${pkAt}`); $(`cmdk-opt-${pkAt}`)?.scrollIntoView({ block: 'nearest' }); }
else $('cmdk-input').removeAttribute('aria-activedescendant');
}
function pkOpen() {
const d = $('cmdk-dialog');
if (d.open) return;
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
if (d.open) { pkItems(); pkFill(); }
});
}
function pkGo(href) {
pkBack = null; $('cmdk-dialog').close();
if (location.hash === href || (href === '#/' && !location.hash.startsWith('#/'))) { render(true); return; }
location.hash = href;
}
$('cmdk-dialog').addEventListener('close', () => { const b = pkBack; pkBack = null; if (b && document.contains(b)) b.focus(); });
$('cmdk-input').addEventListener('input', () => { pkAt = 0; pkFill(); });
$('cmdk-input').addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (pkShown.length) { pkAt = (pkAt + (e.key === 'ArrowDown' ? 1 : -1) + pkShown.length) % pkShown.length; pkFill(); } }
else if (e.key === 'Enter') { e.preventDefault(); if (pkShown[pkAt]) pkGo(pkShown[pkAt].href); }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pkOpen(); return; }
// Up and down move between the task links of a table, as on the board's session table.
const a = e.target.closest?.('#bus-view table.s1-table td.s1-title a');
if (!a || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
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
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
})();