packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
213 lines
9.6 KiB
JavaScript
213 lines
9.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { world, sleep } from './world.mjs';
|
|
const ready = async (t, edit) => {
|
|
const w = await world(t, edit);
|
|
await w.adapter.start('demo');
|
|
assert.equal(w.adapter.status()[0].state, 'ready');
|
|
return w;
|
|
};
|
|
const create = (w, title = 'a task') =>
|
|
w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-S-1' });
|
|
const external = (w, subject) => w.events('task.changed.external').filter((e) => subject === undefined || e.subject === subject);
|
|
// A fake whose clock runs an hour behind, so every task sits outside the cursor window and only the
|
|
// board or a task read can see it.
|
|
const behind = { fake: { now: () => Date.now() - 3600000 } };
|
|
test('an edit in the UI is recorded once, with the fields that changed', async (t) => {
|
|
const w = await ready(t);
|
|
const { task_ref } = await create(w);
|
|
await w.ui('PATCH', '/tasks/1', { title: 'renamed', priority: 2 });
|
|
await sleep(20);
|
|
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 1, events: 1 });
|
|
const [e] = external(w, task_ref);
|
|
assert.deepEqual(e.body.changed, ['title', 'priority']);
|
|
assert.equal(e.body.via, 'cursor');
|
|
assert.equal(e.actor_role, null);
|
|
const s = w.snapshots(task_ref).at(-1);
|
|
assert.equal(s.source, 'poll');
|
|
assert.equal(s.fields.title, 'renamed');
|
|
assert.equal(s.digest, e.body.digest);
|
|
// The overlap window reads the task again; the digest matches, so nothing new is recorded.
|
|
await sleep(20);
|
|
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
|
assert.equal(w.broker.taskView('demo', 'current', task_ref).digest, e.body.digest);
|
|
});
|
|
test('a move between open buckets is seen on the board, though updated does not change', async (t) => {
|
|
const w = await ready(t, behind);
|
|
const { task_ref } = await create(w);
|
|
const updated = w.fake.task(1).updated;
|
|
await w.ui('PUT', `/projects/${w.project}/views/${w.installed.view}/buckets/${w.installed.buckets.blocked}/tasks`, { task_id: 1 });
|
|
assert.equal(w.fake.task(1).updated, updated);
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
const [e] = external(w, task_ref);
|
|
assert.equal(e.body.via, 'board');
|
|
assert.deepEqual(e.body.changed, ['bucket']);
|
|
assert.deepEqual(
|
|
w.broker.taskView('demo', 'open').map((r) => ({ ...r })),
|
|
[{ task_ref, bucket: w.installed.buckets.blocked }],
|
|
);
|
|
});
|
|
test("a person's comment is counted and a bot's is not", async (t) => {
|
|
const w = await ready(t);
|
|
const { task_ref } = await create(w);
|
|
await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' });
|
|
await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, comment: 'from the coder' });
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'from a person' });
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
const [e] = external(w, task_ref);
|
|
assert.equal(e.body.comments, 1);
|
|
assert.deepEqual(e.body.changed, []);
|
|
// A comment changes no digest field, so it is an event without a snapshot.
|
|
assert.equal(w.snapshots(task_ref).at(-1).source, 'self');
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(external(w, task_ref).length, 1);
|
|
});
|
|
test('the hourly reconcile catches a comment through comment_count', async (t) => {
|
|
const w = await ready(t);
|
|
const { task_ref } = await create(w);
|
|
await sleep(20);
|
|
await w.adapter.reconcile('demo');
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'late' });
|
|
await sleep(20);
|
|
await w.adapter.reconcile('demo');
|
|
const [e] = external(w, task_ref);
|
|
assert.equal(e.body.via, 'reconcile');
|
|
assert.equal(e.body.comments, 1);
|
|
// The tick after it reads the same comment list and finds nothing newer.
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(external(w, task_ref).length, 1);
|
|
});
|
|
test('a task closed in the UI leaves the open view with its done bucket', async (t) => {
|
|
for (const edit of [{}, behind]) {
|
|
const w = await ready(t, edit);
|
|
const { task_ref } = await create(w);
|
|
await w.ui('PATCH', '/tasks/1', { done: true });
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
const [e] = external(w, task_ref);
|
|
// The cursor sees a recent close; an old one is found by reading the task the board dropped.
|
|
assert.equal(e.body.via, edit.fake ? 'task' : 'cursor');
|
|
assert.deepEqual(e.body.changed.sort(), ['bucket', 'done']);
|
|
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, w.installed.buckets.done);
|
|
assert.deepEqual(w.broker.taskView('demo', 'open'), []);
|
|
await sleep(20);
|
|
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
|
}
|
|
});
|
|
test('a task that leaves the board is recorded as deleted, moved or out of reach', async (t) => {
|
|
const w = await ready(t);
|
|
const shared = w.fake.project('Shared', w.owner);
|
|
w.fake.share(shared, w.bots.sync, 0);
|
|
const hidden = w.fake.project('Launchpad', w.owner);
|
|
const refs = [];
|
|
for (const title of ['deleted', 'moved', 'hidden']) refs.push((await create(w, title)).task_ref);
|
|
await w.ui('DELETE', '/tasks/1');
|
|
w.fake.moveToProject(2, shared);
|
|
w.fake.moveToProject(3, hidden);
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.deepEqual(
|
|
w.events('task.missing').map((e) => [e.subject, e.body]),
|
|
[
|
|
[refs[0], { reason: 'not-found' }],
|
|
[refs[1], { reason: 'moved', project: shared }],
|
|
[refs[2], { reason: 'no-access' }],
|
|
],
|
|
);
|
|
assert.deepEqual(w.snapshots(refs[1]).at(-1).fields, { gone: 'moved', project: shared });
|
|
// The hidden project's title never reaches the bus.
|
|
assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad'));
|
|
assert.deepEqual(w.broker.taskView('demo', 'open'), []);
|
|
await sleep(20);
|
|
assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
|
assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
|
|
await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref: refs[0], role: 'coder' }), (e) => e.code === 'task-not-found');
|
|
});
|
|
test('a poll that read before a verb wrote does not overwrite the verb', async (t) => {
|
|
let skew = 0;
|
|
const w = await ready(t, { adapter: { clock: () => Date.now() + skew } });
|
|
const { task_ref } = await create(w);
|
|
// The broker's clock can run a few ms ahead of the poll's; a read at or before the self snapshot
|
|
// is not newer evidence, and the overlap window rereads it next tick.
|
|
const at = w.broker.taskView('demo', 'current', task_ref).at;
|
|
await w.ui('PATCH', '/tasks/1', { title: 'raced' });
|
|
skew = Date.parse(at) - 1 - Date.now();
|
|
await w.adapter.tick('demo');
|
|
skew = 0;
|
|
assert.equal(external(w, task_ref).length, 0);
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.deepEqual(external(w, task_ref)[0].body.changed, ['title']);
|
|
});
|
|
test('a tracker fault during a tick is reported and the next tick catches up', async (t) => {
|
|
const w = await ready(t);
|
|
await create(w);
|
|
await w.ui('PATCH', '/tasks/1', { title: 'while down' });
|
|
w.fake.fault('GET', /\/buckets\/tasks$/, 503);
|
|
await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-unavailable');
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
assert.equal(external(w).length, 1);
|
|
});
|
|
test('a malformed answer refuses the tick with tracker-shape', async (t) => {
|
|
const w = await ready(t);
|
|
await create(w);
|
|
// A 200 whose body is an error object, not the board.
|
|
w.fake.fault('GET', /\/buckets\/tasks$/, 200, { apply: true });
|
|
await assert.rejects(w.adapter.tick('demo'), (e) => e.code === 'tracker-shape');
|
|
});
|
|
test('no token value reaches the database, the log or a refusal', async (t) => {
|
|
const { readdirSync, readFileSync, statSync } = await import('node:fs');
|
|
const { join } = await import('node:path');
|
|
const w = await ready(t);
|
|
const { task_ref } = await create(w);
|
|
await w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' });
|
|
await w.call(w.agent('coder'), 'task.update.assigned', { task_ref, state: 'in-progress', comment: 'x' });
|
|
await w.ui('PATCH', '/tasks/1', { title: 'y' });
|
|
w.fake.fault('PUT', /\/tasks$/, 500);
|
|
await assert.rejects(w.call(w.caps.coder, 'task.update.assigned', { task_ref, state: 'blocked' }));
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
await w.adapter.reconcile('demo');
|
|
w.store.all('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
const files = [];
|
|
const walk = (d) => {
|
|
for (const f of readdirSync(d)) {
|
|
const p = join(d, f);
|
|
if (statSync(p).isDirectory()) walk(p);
|
|
else files.push(p);
|
|
}
|
|
};
|
|
walk(w.root);
|
|
assert.ok(files.length > 0);
|
|
const values = Object.values(w.tokens);
|
|
for (const f of files) {
|
|
const bytes = readFileSync(f, 'latin1');
|
|
for (const v of values) assert.ok(!bytes.includes(v), `a token value is in ${f}`);
|
|
}
|
|
for (const v of values) assert.ok(!JSON.stringify(w.log).includes(v));
|
|
// A token pasted into a verb argument is refused by the broker before the adapter sees it.
|
|
await assert.rejects(
|
|
w.call(w.caps.coder, 'task.update.assigned', { task_ref, comment: w.tokens.coder }),
|
|
(e) => e.code !== undefined && !e.message.includes(w.tokens.coder),
|
|
);
|
|
});
|
|
// The first look at a task counts only comments inside the window, so a restart doesn't replay
|
|
// every comment ever made as new.
|
|
test('the first look at a task counts only comments inside the window', async (t) => {
|
|
let shift = -2 * 3600000;
|
|
const w = await ready(t, { fake: { now: () => Date.now() + shift } });
|
|
const { task_ref } = await create(w);
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' });
|
|
shift = 0;
|
|
await w.ui('POST', '/tasks/1/comments', { comment: 'just now' });
|
|
await sleep(20);
|
|
await w.adapter.tick('demo');
|
|
const [e] = external(w, task_ref);
|
|
assert.equal(e.body.comments, 1);
|
|
});
|