roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"] to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the writer check before it looks at authority, so the grant could never be used. A coder's task.reassign now classifies as gated and goes to the human. Backed by lead decision 90. Tests: business role and resolve (the narrowing test moves to the pm, which still holds two cross-role actions), a bus test on the shipped role files, a harness test that a coder with a tracker gets no task_reassign, the tasks fixture, and a verbs line showing a coder's task.reassign refuses field-writer with no action.allowed. mutants.sh is Rocko's packet rerun script, already committed. Author: Rocko (packet agents/rocko/work/queue-73/, commit2137dd7f). Candidate manifest sha256 bf4e5ed6, base324c064b, no drift on the candidate paths through8a42e819. Approval: Dewey, round 1, comment 27506 on #1567. Gate (Sage, /tmp/sage-g73 on8a42e819, 23:42-23:48Z): tasks 71, bus 81, business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed; every scripts/test-*.sh exit 0, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
172 lines
6.7 KiB
JavaScript
172 lines
6.7 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.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,
|
|
};
|
|
}
|