The bus tasks read returns each task's priority and requirement, and the Console's task list shows them. Files: packages/bus/src/broker.mjs and README, packages/bus/tests/task-read.test.mjs, packages/webui/README.md, src/public/bus.css and bus.js, tests/bus.test.mjs and bus-browser.test.mjs. Author: Darkwing (packet agents/darkwing/work/queue-58/, round 2 in48b0b648, rebased on row 74's tree). Candidate manifest sha256 eb2b26c1, base424ada49, no drift on the 8 paths througheace3fef. Approvals, round 2: Filbert comment 27529, Dewey comment 27530 on #1547. Gate (Sage, /tmp/sage-g58 oneace3fef, 23:57-00:01Z): bus 84, tasks 71, webui 61, control-board 124, cli 113 node tests, 0 failed; every scripts/test-*.sh exit 0, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
152 lines
7.3 KiB
JavaScript
152 lines
7.3 KiB
JavaScript
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]]);
|
|
});
|