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 ? '(no title in the current read)' : txt(t.title)}`; + const priority = p => p === null ? 'none' : esc(PRIORITY[p] || p); function taskRow(t) { - return `${title(t)}${t.assignees.map(user).join(', ') || 'none'}${due(t.due)}${esc(PRIORITY[t.priority] || t.priority)}${esc(t.percent)}%${when(t.changedAt)}${markers(t) || 'none'}`; + return `${title(t)}${t.requirement ? txt(t.requirement) : 'none'}${t.assignees.map(user).join(', ') || 'none'}${due(t.due)}${priority(t.priority)}${esc(t.percent)}%${when(t.changedAt)}${markers(t) || 'none'}`; } - const taskTable = (rows, label) => `
${['Task', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => ``).join('')}${rows.map(taskRow).join('')}
${h}
`; + const taskTable = (rows, label) => `
${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => ``).join('')}${rows.map(taskRow).join('')}
${h}
`; 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'}. Poller status: from the S3 status read, not built yet`; @@ -318,7 +321,7 @@ const decisions = t.decisions.length ? `
Decisions
${t.decisions.map(d => `${txt(short(d.id))} ${txt(first(d.question))}`).join('
')}
` : ''; 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)}`)} -

Task

Ref
${txt(t.ref)} ${gap('The Vikunja address')}
Assignees
${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}
Requirement
${txt(t.requirement || 'none')}
Due
${due(t.due)}
Priority
${esc(PRIORITY[t.priority] || t.priority)}
Done
${esc(t.percent)}%
${ext}${conflicts}${decisions}
+

Task

Ref
${txt(t.ref)} ${gap('The Vikunja address')}
Assignees
${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}
Requirement
${txt(t.requirement || 'none')}
Due
${due(t.due)}
Priority
${priority(t.priority)}
Done
${esc(t.percent)}%
${ext}${conflicts}${decisions}
${t.description ? `

Description

${txt(t.description)}

` : ''}
Snapshots ${t.snapshots.length}
    ${t.snapshots.map(s => `
  1. ${clock(s.at)} ${esc(bucketText(s.fields))} ${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' : ''}
  2. `).join('')}

Trail

${trailList(lines(tr, row.seq), 'all')}

Trail with filters

`; 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']]);