Round 1's build.patch applies cleanly on424ada49(row 74 landed as351464ce). 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]>
368 lines
26 KiB
Diff
368 lines
26 KiB
Diff
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']]);
|