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]>
172 lines
6.8 KiB
JavaScript
172 lines
6.8 KiB
JavaScript
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Store } from '../../bus/src/store.mjs';
|
|
import { Broker } from '../../bus/src/broker.mjs';
|
|
import { Credentials } from '../../bus/src/credentials.mjs';
|
|
import { FakeVikunja } from '../src/fake.mjs';
|
|
import { tasksAdapter } from '../src/adapter.mjs';
|
|
// Token scopes as the runbook mints them. The sync token reads; role tokens write but never delete,
|
|
// and only the pm's adds labels (addendum B section 2).
|
|
export const SCOPES = {
|
|
sync: {
|
|
projects: ['read_one', 'views_buckets', 'views_buckets_tasks_get'],
|
|
projects_views: ['read_all'],
|
|
tasks: ['read_all', 'read_one'],
|
|
tasks_comments: ['read_all'],
|
|
},
|
|
pm: {
|
|
tasks: ['read_one', 'create', 'update'],
|
|
tasks_labels: ['create', 'delete'],
|
|
tasks_assignees: ['create', 'delete'],
|
|
tasks_relations: ['create', 'delete'],
|
|
tasks_comments: ['create'],
|
|
projects: ['views_buckets_tasks'],
|
|
labels: ['read_all'],
|
|
},
|
|
worker: { tasks: ['read_one', 'update'], tasks_comments: ['create'], projects: ['views_buckets_tasks'] },
|
|
};
|
|
export const AUTHORITY = {
|
|
pm: {
|
|
withinRole: [
|
|
'task.create',
|
|
'task.assign',
|
|
'task.reassign',
|
|
'task.schedule',
|
|
'task.update.assigned',
|
|
'task.close',
|
|
'role.launch',
|
|
'message.send',
|
|
],
|
|
crossRole: ['task.priority.change', 'task.scope.change'],
|
|
},
|
|
coder: {
|
|
withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'],
|
|
crossRole: ['task.reassign', 'task.scope.change'],
|
|
},
|
|
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
|
|
cto: {
|
|
withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'],
|
|
crossRole: ['task.scope.change'],
|
|
},
|
|
};
|
|
export const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
// One business on a fresh fake: owner svc-demo, four bots it owns, the installed project shared
|
|
// with them, one allowed label. `edit` adjusts the setup before the adapter starts; `edit.adapter`
|
|
// overrides tasksAdapter options. `edit.fake` is FakeVikunja options or an instance.
|
|
export async function world(t, edit = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'tasks-'));
|
|
const fake = edit.fake instanceof FakeVikunja ? edit.fake : new FakeVikunja(edit.fake);
|
|
const owner = fake.user('svc-demo');
|
|
const bots = {};
|
|
for (const r of ['sync', 'pm', 'coder', 'reviewer']) bots[r] = fake.user('bot-demo-' + r, { owner });
|
|
const project = fake.project('Demo', owner);
|
|
for (const r of ['pm', 'coder', 'reviewer']) fake.share(project, bots[r], 1);
|
|
fake.share(project, bots.sync, 0);
|
|
const installed = edit.install === false ? null : fake.install(project);
|
|
const label = fake.label('slice-1', owner);
|
|
const scopes = { sync: SCOPES.sync, pm: SCOPES.pm, coder: SCOPES.worker, reviewer: SCOPES.worker, ...edit.scopes };
|
|
const env = {};
|
|
const tokens = {};
|
|
for (const r of Object.keys(bots)) {
|
|
tokens[r] = fake.token(bots[r], scopes[r]);
|
|
env['VK_' + r.toUpperCase()] = tokens[r];
|
|
}
|
|
tokens.owner = fake.token(owner);
|
|
const expires = { sync: '2099-01-01', pm: '2099-01-01', coder: '2099-01-01', reviewer: '2099-01-01', ...edit.expires };
|
|
const ref = (r) => (edit.files?.[r] ? { file: edit.files[r], expires: expires[r] } : { env: 'VK_' + r.toUpperCase(), expires: expires[r] });
|
|
if (edit.files)
|
|
for (const [r, file] of Object.entries(edit.files)) {
|
|
writeFileSync(file, tokens[r] + '\n');
|
|
chmodSync(file, 0o600);
|
|
}
|
|
const roles = {};
|
|
for (const r of ['pm', 'coder', 'reviewer', 'cto'])
|
|
roles[r] = {
|
|
definition: r,
|
|
authority: AUTHORITY[r],
|
|
...(r === 'cto' ? {} : { tracker: { bot: 'bot-demo-' + r, botId: bots[r] }, credentials: { vikunja: ref(r) } }),
|
|
};
|
|
const businesses = {
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'cto', delivery: 'pm' },
|
|
roles,
|
|
tracker: {
|
|
sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } },
|
|
labels: { 'slice-1': label, ...edit.labels },
|
|
},
|
|
},
|
|
};
|
|
edit.business?.(businesses.demo, { fake, owner, bots, project });
|
|
const references = {};
|
|
for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials;
|
|
if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials;
|
|
const credentials = new Credentials({ references, env, dataRoot: root });
|
|
const store = new Store(root);
|
|
const broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) });
|
|
const log = [];
|
|
const factory = tasksAdapter({
|
|
trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } },
|
|
fetch: edit.fetch ?? fake.fetch,
|
|
log: (line) => log.push(line),
|
|
autostart: false,
|
|
...edit.adapter,
|
|
});
|
|
const adapter = await factory({ broker, credentials, businesses });
|
|
t.after(async () => {
|
|
await adapter.close();
|
|
store.close();
|
|
credentials.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const caps = {};
|
|
const agent = (role) => {
|
|
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
|
|
broker.request(cap, { verb: 'role.claim' });
|
|
return (caps[role] = cap);
|
|
};
|
|
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
// A human instruction, the request a task.create cites.
|
|
const instruction = () => broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
|
// The owner acting in the tracker UI, which the poller must notice.
|
|
const ui = async (method, path, body) => {
|
|
const r = await fake.fetch('http://vikunja.test/api/v2' + path, {
|
|
method,
|
|
headers: { authorization: 'Bearer ' + tokens.owner, 'content-type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
return { status: r.status, json: r.status === 204 ? null : await r.json() };
|
|
};
|
|
const events = (kind) =>
|
|
store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
|
const snapshots = (taskRef) =>
|
|
store.all('SELECT * FROM task_snapshots WHERE task_ref=? ORDER BY seq', taskRef).map((s) => ({ ...s, fields: JSON.parse(s.fields) }));
|
|
const call = (cap, verb, args) => broker.requestTask(cap, { verb, args }, adapter.handle);
|
|
return {
|
|
root,
|
|
fake,
|
|
owner,
|
|
bots,
|
|
project,
|
|
installed,
|
|
label,
|
|
tokens,
|
|
credentials,
|
|
businesses,
|
|
store,
|
|
broker,
|
|
adapter,
|
|
log,
|
|
caps,
|
|
agent,
|
|
human,
|
|
instruction,
|
|
ui,
|
|
events,
|
|
snapshots,
|
|
call,
|
|
};
|
|
}
|