diff --git a/packages/bus/README.md b/packages/bus/README.md index 5b57b7fc8..7fa51f92b 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 84db87997..595ca2327 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 000000000..2f353d1b2 --- /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 883b5a6d1..bb7fc399b 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 39cf29917..acf496a53 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 affb9b7b5..3be22af56 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 `
| ${h} | `).join('')}
|---|
| ${h} | `).join('')}
|---|
${txt(t.ref)} ${gap('The Vikunja address')}${txt(t.ref)} ${gap('The Vikunja address')}${txt(t.description)}
` : ''}