Files
stack/agents/darkwing/work/queue-58/build.patch
T
jason.woltjeandClaude Opus 5.5 48b0b64869 review(58): Darkwing round 2 candidate, rebased on row 74's tree (#1547)
Round 1's build.patch applies cleanly on 424ada49 (row 74 landed as 351464ce).
Only packages/webui/README.md's digest changes; the bullet text is the same.
Manifest eb2b26c1. Gate 23:41:10Z-23:44:25Z: bus 83, tasks 71, webui 61, every
scripts/test-*.sh green. 16 of 17 mutants fail, output identical to round 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:49:52 -05:00

368 lines
26 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 5b57b7fc..7fa51f92 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -221,7 +221,11 @@ await read.decisions('roles'); await read.decisions('recent');
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
The tasks projection reports the schema’s `task_current` view, including tombstones; the caller
-must not pretend a missing/inaccessible task is open or completed. Trail joins
+must not pretend a missing/inaccessible task is open or completed. Each row adds two keys,
+computed at each read and never stored. `priority` is the current row's `fields.priority`, null
+for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
+first `task.created` for the same Vikunja task id in the business, so a task moved to another
+project keeps it; null for a task the stack didn't create. Trail joins
human request, messages/deliveries, decisions/resolutions, task snapshots and
session/claim evidence. Broker timestamps advance monotonically across all
its writes, including after reopen; per-table `seq` stays the primary table
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 84db8799..595ca232 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -303,6 +303,26 @@ export class Broker {
id,
);
}
+ // Row 58 (#1547): the 'tasks' rows, each with the task's priority and requirement. Priority
+ // is the current row's; a tombstone has none. The requirement is the first task.created for
+ // the same Vikunja task id, which a move to another project keeps; null for a task the stack
+ // didn't create. Both come from append-only rows at each read; nothing is stored.
+ #taskRows(business) {
+ const taskId = (ref) => ref.slice(ref.indexOf('/') + 1);
+ const requirement = new Map();
+ for (const e of this.#store.all(
+ "SELECT subject,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq",
+ business,
+ ))
+ if (!requirement.has(taskId(e.subject))) requirement.set(taskId(e.subject), e.requirement);
+ return this.#store
+ .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business)
+ .map((r) => {
+ const fields = JSON.parse(r.fields);
+ const priority = fields.priority ?? null;
+ return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
+ });
+ }
#checkAuthority(s, action, { decision = null, target = null } = {}) {
this.#agent(s);
if (
@@ -418,7 +438,8 @@ export class Broker {
s.run,
).lastInsertRowid;
}
- // Trusted S3 reads. Agents read the same rows through the 'tasks' view verb.
+ // Trusted S3 reads. Agents read the same rows through the 'tasks' view verb, which adds
+ // priority and requirement (#taskRows).
taskView(business, view, arg = null) {
this.#business(business);
const parse = (r) => ({ ...r, fields: JSON.parse(r.fields) });
@@ -830,10 +851,7 @@ export class Broker {
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
s.business,
);
- if (verb === 'tasks')
- return this.#store
- .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', s.business)
- .map((r) => ({ ...r, fields: JSON.parse(r.fields) }));
+ if (verb === 'tasks') return this.#taskRows(s.business);
return this.#trail(s, identifier(a.subject));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
diff --git a/packages/bus/tests/task-read.test.mjs b/packages/bus/tests/task-read.test.mjs
new file mode 100644
index 00000000..2f353d1b
--- /dev/null
+++ b/packages/bus/tests/task-read.test.mjs
@@ -0,0 +1,151 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker } from '../src/broker.mjs';
+// Row 58 (#1547): each 'tasks' row carries the task's priority and requirement, read from the
+// append-only task_snapshots and task.created rows, for every session that may read tasks.
+const roles = { pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } } };
+const businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
+ other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
+};
+const at = '2026-10-08T12:00:00.000Z';
+// task_current keeps a poll only when it was read after the last self snapshot, whose `at` is the
+// broker's clock, so a read after a write must be later than now.
+const later = new Date(Date.now() + 3600e3).toISOString();
+const digest = 'c'.repeat(64);
+const fields = (extra = {}) => ({ bucket: 11, done: false, priority: 0, ...extra });
+const poll = (task_ref, f, extra = {}) => ({ task_ref, updated: at, digest, fields: f, via: 'board', read_at: at, ...extra });
+const gone = (task_ref, f) => poll(task_ref, f, { via: 'task', read_at: later });
+const tasks = (b, cap) => b.request(cap, { verb: 'tasks', args: {} });
+const pick = (rows) => rows.map((r) => [r.task_ref, r.priority, r.requirement]);
+function open(root) {
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const agent = (business) => {
+ const cap = b.bindLaunch({ business, role: 'pm', run: `${business}-pm`, harness: 'pi' });
+ b.request(cap, { verb: 'role.claim' });
+ return cap;
+ };
+ return { store, b, human, reader: b.bindReader({ business: 'demo' }), agent };
+}
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-task-read-'));
+ const bus = { root, ...open(root) };
+ t.after(() => {
+ bus.store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ return bus;
+}
+// The pm creates a task the way packages/tasks does: a self snapshot and task.created.
+function create(b, cap, human, business, task_ref, requirement, f) {
+ const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } });
+ b.recordTask({
+ cap,
+ business,
+ snapshots: [{ task_ref, updated: at, digest, fields: f }],
+ events: [{ kind: 'task.created', subject: task_ref, body: { request, requirement } }],
+ });
+}
+// Six demo tasks: created by the stack (3/7), changed outside it since (3/8), never seen by the
+// stack (3/9), created then gone (3/10), created then moved to project 4 (3/11 -> 4/11), and a
+// second task.created for 4/11's id.
+function seed(bus) {
+ const { b, human, agent } = bus;
+ const pm = agent('demo');
+ create(b, pm, human, 'demo', 'vikunja:3/7', 'REQ-SLICE-1', fields({ priority: 3 }));
+ create(b, pm, human, 'demo', 'vikunja:3/8', 'REQ-SLICE-2', fields({ priority: 1 }));
+ create(b, pm, human, 'demo', 'vikunja:3/10', 'REQ-SLICE-3', fields({ priority: 4 }));
+ create(b, pm, human, 'demo', 'vikunja:3/11', 'REQ-MOVE-1', fields({ priority: 2 }));
+ b.recordTask({
+ business: 'demo',
+ snapshots: [
+ poll('vikunja:3/8', fields({ priority: 5 }), { read_at: later }),
+ poll('vikunja:3/9', fields()),
+ gone('vikunja:3/10', { gone: 'no-access' }),
+ gone('vikunja:3/11', { gone: 'moved', project: 4 }),
+ poll('vikunja:4/11', fields({ priority: 2, bucket: 21 }), { via: 'reconcile', read_at: later }),
+ ],
+ });
+ create(b, pm, human, 'demo', 'vikunja:4/11', 'REQ-MOVE-2', fields({ priority: 2, bucket: 21 }));
+ // The same task id in another business has its own requirement.
+ const { request } = b.request(b.bindHuman({ business: 'other', human: 'jason', via: 'cli', outsideAgent: true }), {
+ verb: 'message.send',
+ args: { to: 'pm', body: 'please' },
+ });
+ b.recordTask({
+ cap: agent('other'),
+ business: 'other',
+ snapshots: [{ task_ref: 'vikunja:3/7', updated: at, digest, fields: fields({ priority: 1 }) }],
+ events: [{ kind: 'task.created', subject: 'vikunja:3/7', body: { request, requirement: 'REQ-OTHER-1' } }],
+ });
+ return pm;
+}
+const EXPECTED = [
+ ['vikunja:3/10', null, 'REQ-SLICE-3'],
+ ['vikunja:3/11', null, 'REQ-MOVE-1'],
+ ['vikunja:3/7', 3, 'REQ-SLICE-1'],
+ ['vikunja:3/8', 5, 'REQ-SLICE-2'],
+ ['vikunja:3/9', 0, null],
+ ['vikunja:4/11', 2, 'REQ-MOVE-1'],
+];
+
+test('tasks rows carry priority and requirement for every session that reads tasks', (t) => {
+ const bus = setup(t);
+ const pm = seed(bus);
+ for (const cap of [bus.human, bus.reader, pm]) assert.deepEqual(pick(tasks(bus.b, cap)), EXPECTED);
+ // The priority is the current row's: a later poll wins over the stack's write (3/8), and Vikunja's
+ // 0 stays 0, not null (3/9). A tombstone has no priority (3/10, 3/11).
+ // The requirement follows the task id: 4/11 keeps the one 3/11 was created with, not the second
+ // task.created, and a task the stack never created has none (3/9).
+ const other = bus.b.bindReader({ business: 'other' });
+ assert.deepEqual(pick(tasks(bus.b, other)), [['vikunja:3/7', 1, 'REQ-OTHER-1']]);
+ // Nothing else in the row changes: it is task_current's row with two keys added.
+ const current = bus.b.taskView('demo', 'current');
+ assert.deepEqual(
+ tasks(bus.b, bus.reader).map(({ priority, requirement, ...r }) => ({ ...r })),
+ current.map((r) => ({ ...r })),
+ );
+ assert.equal('priority' in current[0] || 'requirement' in current[0], false);
+});
+
+test('the read model rebuilds from the ledger: a reopened bus and each trail give the same values', (t) => {
+ const bus = setup(t);
+ seed(bus);
+ const before = tasks(bus.b, bus.reader);
+ bus.store.close();
+ Object.assign(bus, open(bus.root));
+ const after = tasks(bus.b, bus.reader);
+ assert.deepEqual(after, before);
+ assert.deepEqual(pick(after), EXPECTED);
+ // Each value is what the append-only rows say: the priority of the current snapshot, and the
+ // requirement of the first task.created for the task's id, read through trail.
+ for (const row of after) {
+ const trail = bus.b.request(bus.reader, { verb: 'trail', args: { subject: row.task_ref } });
+ const snap = trail.find((r) => r.table === 'task_snapshots' && r.seq === row.seq);
+ assert.equal(snap.fields.priority ?? null, row.priority, row.task_ref);
+ const id = row.task_ref.split('/')[1];
+ const created = bus.store
+ .all("SELECT subject, body FROM events WHERE business='demo' AND kind='task.created' ORDER BY seq")
+ .find((e) => e.subject.split('/')[1] === id);
+ assert.equal(created ? JSON.parse(created.body).requirement : null, row.requirement, row.task_ref);
+ }
+ // A read writes nothing.
+ const count = () => bus.store.get('SELECT (SELECT count(*) FROM events) + (SELECT count(*) FROM task_snapshots) n').n;
+ const n = count();
+ tasks(bus.b, bus.human);
+ tasks(bus.b, bus.reader);
+ assert.equal(count(), n);
+});
+
+test('an empty business and one with snapshots but no task.created read cleanly', (t) => {
+ const bus = setup(t);
+ assert.deepEqual(tasks(bus.b, bus.reader), []);
+ bus.b.recordTask({ business: 'demo', snapshots: [poll('vikunja:5/1', { bucket: 0, done: false })] });
+ assert.deepEqual(pick(tasks(bus.b, bus.reader)), [['vikunja:5/1', null, null]]);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 883b5a6d..bb7fc399 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -153,6 +153,10 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
since the list has no trail. The task page reads the trail and tells a task
the stack never created from one changed in Vikunja after the stack wrote it.
A stale poll read is shown in the snapshots and never as current.
+- The Tasks table has Requirement and Priority columns, from the `tasks` row's
+ `requirement` and `priority` (row 58, #1547). Either one shows "none" when
+ the row has none. A tombstone's page shows the priority of the last snapshot
+ that read the task.
- All bus- and agent-authored text is rendered inert: controls show as control
pictures or `[U+XXXX]`, and Markdown stays as source.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css
index 39cf2991..acf496a5 100644
--- a/packages/webui/src/public/bus.css
+++ b/packages/webui/src/public/bus.css
@@ -25,7 +25,7 @@ body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
/* Tables */
-table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
+table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:1020px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index affb9b7b..3be22af5 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -105,8 +105,10 @@
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 : [],
- requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
- due: shape.due_date ?? null, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
+ // 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,
@@ -287,10 +289,11 @@
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.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
+ 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', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
+ 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>`;
@@ -318,7 +321,7 @@
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>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
+ <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>`;
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index aebd3c30..9faa15eb 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -46,7 +46,7 @@ function seed(root) {
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
// A task the stack never wrote, a done task and one no longer readable.
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
- b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
+ b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100, priority: undefined } }] });
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
@@ -168,7 +168,14 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
assert.match(tasks, /#10 \(no title in the current read\)/);
- assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
+ assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/);
+ // Requirement and priority come from the bus row (row 58). The list reads no trail, so #7's
+ // requirement is the row's. #8 and #9 have no task.created, and #9 was written with no priority.
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-tasks")].map(t=>[[...t.tHead.rows[0].cells].map(c=>c.textContent),...[...t.tBodies[0].rows].map(r=>[r.cells[1].textContent,r.cells[4].textContent])])'), [
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'high']],
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['REQ-SLICE-1', 'high']],
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'none']],
+ ]);
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
@@ -178,6 +185,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
await go('#/tasks/vikunja%3A32%2F7', '#7');
const t7 = await text('#bus-view');
assert.match(t7, /RequirementREQ-SLICE-1/);
+ assert.match(t7, /Priorityhigh/);
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
@@ -194,6 +202,8 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
await go('#/tasks/vikunja%3A32%2F10', '#10');
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
+ // A tombstone's bus row has no priority; the detail shows the last snapshot that read the task.
+ assert.match(await text('#bus-view'), /Priorityhigh/);
// The trail with filters.
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
@@ -238,6 +248,11 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
await noOverflow('refused');
+ // #9 was written with no priority and has no task.created. This runs after the never-read
+ // check above, which needs #9's trail unread.
+ await go('#/tasks/vikunja%3A32%2F9', '#9');
+ assert.match(await text('#bus-view'), /Requirementnone/);
+ assert.match(await text('#bus-view'), /Prioritynone/);
// Back to the board: it still works.
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index 2d9ca6ba..cf477052 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -67,6 +67,9 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
assert.equal(tasks.body.rows.length, 1);
assert.equal(tasks.body.rows[0].source, 'poll');
assert.equal(tasks.body.rows[0].fields.title, HOSTILE);
+ // The bus row's priority and requirement (row 58) reach the page: this poll has no priority and
+ // no task.created, so both are null, not missing.
+ assert.deepEqual([tasks.body.rows[0].priority, tasks.body.rows[0].requirement], [null, null]);
const agents = await read('/api/bus/agents');
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);