Row 89, round 1. The broker's `tasks` read takes optional filter and page arguments and returns a short form. The harness adds `list_tasks` arguments (open tasks by default), a `get_task` tool, and compact JSON replies capped below the 50000-character threshold where Claude Code 2.1.296 saves a tool result to a file outside the workspace, which was the PM's fault in Jason's test. The bundle's tools line names the harness's own tools. Candidate manifest b8c5bb3a (agents/dewey/work/queue-89/), basea3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by Darkwing (comment 27940). Sage's gate onf04185fcplus the patch 2026-10-12T22:00:55Z-22:03:38Z: bus 108, harness 65, webui 86, cli 115, control-board 124, every scripts/test-*.sh green. Live at the next bus restart (broker) and role session launch (harness), both Mos's call (decision 109); Mos had the notice. Co-Authored-By: Claude Opus 5.5 <[email protected]>
226 lines
10 KiB
JavaScript
226 lines
10 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, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs';
|
|
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
|
// Row 89 (#1583): `tasks` with arguments filters and pages a short form a session can read; with
|
|
// none it is the whole board, as the Console and `mosaic tasks` read it.
|
|
const authority = { withinRole: ['message.send', 'task.create'], crossRole: [] };
|
|
const roles = {
|
|
pm: { authority, tracker: { bot: 'bot-pm', botId: 101 } },
|
|
coder: { authority, tracker: { bot: 'bot-coder', botId: 102 } },
|
|
reviewer: { authority, tracker: null },
|
|
};
|
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } };
|
|
const at = '2026-10-12T12:00:00.000Z';
|
|
const digest = 'd'.repeat(64);
|
|
const N = 500;
|
|
const words = 'the board holds this task so a session can page through it without the description '.repeat(30);
|
|
const ref = (i) => `vikunja:3/${i}`;
|
|
// Task i: done when i % 5 == 0; the pm's bot on i % 3 == 0, the coder's on i % 4 == 0, a person (7)
|
|
// on i % 2 == 0; label 9 on i % 7 == 0; tombstones at 13 and 26.
|
|
const fieldsOf = (i) => ({
|
|
project_id: 3,
|
|
title: `Task ${i}: a title of ordinary length for a tracker card`,
|
|
description: `<p>${i} ${words}</p>`,
|
|
done: i % 5 === 0,
|
|
due_date: null,
|
|
priority: i % 4,
|
|
percent_done: 0,
|
|
bucket: i % 5 === 0 ? 12 : 11,
|
|
labels: i % 7 === 0 ? [9, 4] : [4],
|
|
assignees: [...(i % 3 === 0 ? [101] : []), ...(i % 4 === 0 ? [102] : []), ...(i % 2 === 0 ? [7] : [])],
|
|
});
|
|
const GONE = new Set([13, 26]);
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-task-page-'));
|
|
const store = new Store(root);
|
|
const b = new Broker({ store, businesses });
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' });
|
|
b.request(pm, { verb: 'role.claim' });
|
|
b.recordTask({
|
|
business: 'demo',
|
|
snapshots: Array.from({ length: N }, (_, k) => k + 1).map((i) => ({
|
|
task_ref: ref(i),
|
|
updated: at,
|
|
digest,
|
|
fields: GONE.has(i) ? { gone: 'no-access' } : fieldsOf(i),
|
|
via: GONE.has(i) ? 'task' : 'board',
|
|
read_at: at,
|
|
})),
|
|
});
|
|
// Task 2 and task 6 were created by the stack, under two requirements.
|
|
for (const [i, requirement] of [[2, 'REQ-SLICE-1'], [6, 'REQ-SLICE-2']]) {
|
|
const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } });
|
|
b.recordTask({
|
|
cap: pm,
|
|
business: 'demo',
|
|
snapshots: [{ task_ref: ref(i), updated: at, digest, fields: fieldsOf(i) }],
|
|
events: [{ kind: 'task.created', subject: ref(i), body: { request, requirement } }],
|
|
});
|
|
}
|
|
const tasks = (args, cap = pm) => b.request(cap, { verb: 'tasks', args });
|
|
return { b, human, pm, reader: b.bindReader({ business: 'demo' }), tasks };
|
|
}
|
|
const refusesWith = (code) => (e) => e.code === code;
|
|
// Every task, in the broker's order (task_ref, as text).
|
|
const ALL = Array.from({ length: N }, (_, k) => k + 1).sort((x, y) => (ref(x) < ref(y) ? -1 : 1));
|
|
const live = (keep) => ALL.filter((i) => !GONE.has(i) && keep(i)).map(ref);
|
|
// Every page of a read, by following next.
|
|
function pages(tasks, args) {
|
|
const out = [];
|
|
let after;
|
|
for (;;) {
|
|
const page = tasks(after === undefined ? args : { ...args, after });
|
|
out.push(page);
|
|
if (page.next === null) return out;
|
|
after = page.next;
|
|
}
|
|
}
|
|
|
|
test('the session default fits under the reply cap and pages to the end with next', (t) => {
|
|
const { tasks } = setup(t);
|
|
const whole = tasks({});
|
|
assert.equal(whole.length, N);
|
|
assert.ok(JSON.stringify(whole).length > 20 * REPLY_CAP, 'the whole board is far over the cap');
|
|
const read = pages(tasks, { done: false });
|
|
const open = live((i) => i % 5 !== 0);
|
|
assert.equal(read.length, Math.ceil(open.length / TASK_PAGE));
|
|
for (const page of read) {
|
|
assert.ok(JSON.stringify(page).length < REPLY_CAP, `${JSON.stringify(page).length}`);
|
|
assert.equal(page.total, open.length);
|
|
assert.ok(page.tasks.length <= TASK_PAGE);
|
|
}
|
|
assert.deepEqual(read.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
|
|
assert.deepEqual(read.slice(0, -1).map((p) => p.next), read.slice(0, -1).map((p) => p.tasks.at(-1).task_ref));
|
|
assert.equal(read.at(-1).next, null);
|
|
// limit at its most pages the same tasks in fewer pages.
|
|
const big = pages(tasks, { done: false, limit: TASK_PAGE_MAX });
|
|
assert.equal(big.length, Math.ceil(open.length / TASK_PAGE_MAX));
|
|
assert.deepEqual(big.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
|
|
});
|
|
|
|
test('the short form: no description, assignees by role, every listed key', (t) => {
|
|
const { tasks } = setup(t);
|
|
const whole = new Map(tasks({}).map((r) => [r.task_ref, r]));
|
|
const page = tasks({ limit: TASK_PAGE_MAX });
|
|
for (const x of page.tasks) {
|
|
const row = whole.get(x.task_ref);
|
|
const i = Number(x.task_ref.split('/')[1]);
|
|
assert.deepEqual(x, {
|
|
task_ref: row.task_ref,
|
|
title: row.fields.title,
|
|
done: row.fields.done,
|
|
bucket: row.fields.bucket,
|
|
assignees: [...(i % 4 === 0 ? ['coder'] : []), ...(i % 3 === 0 ? ['pm'] : [])],
|
|
labels: row.fields.labels,
|
|
priority: row.priority,
|
|
requirement: row.requirement,
|
|
changed_at: row.at,
|
|
});
|
|
}
|
|
const two = page.tasks.find((x) => x.task_ref === ref(2));
|
|
assert.equal(two.requirement, 'REQ-SLICE-1');
|
|
assert.equal(JSON.stringify(page).includes('description'), false);
|
|
// A tombstone is on the whole board, never in a filtered read.
|
|
assert.deepEqual(whole.get(ref(13)).fields, { gone: 'no-access' });
|
|
const every = pages(tasks, { limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
|
|
assert.deepEqual(every, live(() => true));
|
|
});
|
|
|
|
test('each filter, alone and together, and total counts every match', (t) => {
|
|
const { tasks } = setup(t);
|
|
const refs = (args) => pages(tasks, { ...args, limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
|
|
assert.deepEqual(refs({ done: true }), live((i) => i % 5 === 0));
|
|
assert.deepEqual(refs({ done: false }), live((i) => i % 5 !== 0));
|
|
assert.deepEqual(refs({ assignee: 'pm' }), live((i) => i % 3 === 0));
|
|
assert.deepEqual(refs({ assignee: 'coder' }), live((i) => i % 4 === 0));
|
|
// A role with no tracker bot holds no task; a person's assignment is no role's.
|
|
assert.deepEqual(refs({ assignee: 'reviewer' }), []);
|
|
assert.deepEqual(refs({ label: 9 }), live((i) => i % 7 === 0));
|
|
assert.deepEqual(refs({ label: 4 }), live(() => true));
|
|
assert.deepEqual(refs({ label: 5 }), []);
|
|
assert.deepEqual(refs({ requirement: 'REQ-SLICE-2' }), [ref(6)]);
|
|
assert.deepEqual(refs({ requirement: 'REQ-NONE-1' }), []);
|
|
assert.deepEqual(refs({ done: false, assignee: 'pm', label: 9 }), live((i) => i % 5 !== 0 && i % 3 === 0 && i % 7 === 0));
|
|
const page = tasks({ done: true, assignee: 'coder', limit: 3 });
|
|
assert.equal(page.total, live((i) => i % 5 === 0 && i % 4 === 0).length);
|
|
assert.equal(page.tasks.length, 3);
|
|
assert.equal(page.next, page.tasks[2].task_ref);
|
|
// after needn't be a task on the board, or one the filters match: the page starts past it, in
|
|
// text order (3/500 < 3/501 < 3/51), and total still counts every match.
|
|
const past = tasks({ after: 'vikunja:3/501', limit: 2 });
|
|
assert.deepEqual(past.tasks.map((x) => x.task_ref), [ref(51), ref(52)]);
|
|
assert.equal(past.total, live(() => true).length);
|
|
assert.deepEqual(tasks({ done: true, after: ref(51), limit: 1 }).tasks.map((x) => x.task_ref), [ref(55)]);
|
|
const last = tasks({ after: 'vikunja:9/1' });
|
|
assert.deepEqual(last, { tasks: [], total: live(() => true).length, next: null });
|
|
// A page that ends exactly on the last match has no next.
|
|
const exact = tasks({ requirement: 'REQ-SLICE-1', limit: 1 });
|
|
assert.deepEqual([exact.tasks.length, exact.next], [1, null]);
|
|
});
|
|
|
|
test('task_ref reads one task in full, as the whole board has it', (t) => {
|
|
const { tasks, human, reader } = setup(t);
|
|
const whole = tasks({});
|
|
for (const r of [ref(2), ref(13), ref(500)]) assert.deepEqual(tasks({ task_ref: r }), whole.find((x) => x.task_ref === r));
|
|
assert.ok(tasks({ task_ref: ref(2) }).fields.description.length > 2000);
|
|
assert.deepEqual(tasks({ task_ref: ref(2) }, human), tasks({ task_ref: ref(2) }, reader));
|
|
assert.throws(() => tasks({ task_ref: 'vikunja:3/501' }), refusesWith('task-not-found'));
|
|
assert.throws(() => tasks({ task_ref: 'vikunja:4/2' }), refusesWith('task-not-found'));
|
|
});
|
|
|
|
test('no arguments is the read the Console and mosaic make, unchanged', (t) => {
|
|
const { b, tasks, human, reader } = setup(t);
|
|
const rows = b.taskView('demo', 'current');
|
|
const whole = tasks({});
|
|
assert.deepEqual(whole.map(({ priority, requirement, ...r }) => r), rows);
|
|
assert.deepEqual(tasks({}, human), whole);
|
|
assert.deepEqual(tasks({}, reader), whole);
|
|
assert.deepEqual(b.request(reader, { verb: 'tasks' }), whole);
|
|
});
|
|
|
|
test('an unknown argument or a bad value refuses invalid-request', (t) => {
|
|
const { tasks, reader } = setup(t);
|
|
const bad = [
|
|
{ status: 'open' },
|
|
{ description: 'x' },
|
|
{ done: 'false' },
|
|
{ done: 0 },
|
|
{ done: null },
|
|
{ assignee: 'cto' },
|
|
{ assignee: 'toString' },
|
|
{ assignee: 101 },
|
|
{ label: 0 },
|
|
{ label: -1 },
|
|
{ label: 1.5 },
|
|
{ label: '9' },
|
|
{ requirement: 'REQ-slice-1' },
|
|
{ requirement: 'REQ-SLICE-0' },
|
|
{ requirement: 7 },
|
|
{ limit: 0 },
|
|
{ limit: TASK_PAGE_MAX + 1 },
|
|
{ limit: 2.5 },
|
|
{ limit: '50' },
|
|
{ after: 'vikunja:3' },
|
|
{ after: 3 },
|
|
{ task_ref: 'vikunja:3/x' },
|
|
{ task_ref: ref(2), done: false },
|
|
{ task_ref: ref(2), limit: 1 },
|
|
];
|
|
for (const args of bad) {
|
|
assert.throws(() => tasks(args), refusesWith('invalid-request'), JSON.stringify(args));
|
|
assert.throws(() => tasks(args, reader), refusesWith('invalid-request'), JSON.stringify(args));
|
|
}
|
|
assert.deepEqual([TASK_PAGE, TASK_PAGE_MAX], [50, 200]);
|
|
});
|