Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on manifest 95611274 (21 paths, baseb8539480). The approved PRD's requirement ids live in packages/tasks/requirements.json. task_create refuses an id outside it, and the PM session gets a list_requirements tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES (decision 127). Sage gate onc047d028plus the patch, 23:50:16Z to 23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33, business 60, control-board 124, queue 150, conversation 182, discord 178, ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh exited 0. Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
12 KiB
JavaScript
219 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { world, SCOPES } from './world.mjs';
|
|
import { FakeVikunja } from '../src/fake.mjs';
|
|
import { loadFields, checkFields, FieldTableError, FIELDS_FILE } from '../src/fields.mjs';
|
|
import { TASK_VERBS } from '../../bus/src/broker.mjs';
|
|
import { loadRole } from '../../business/src/index.mjs';
|
|
// Row 61 (#1550): fields.json against what the verbs do. The writer layer is read from the table;
|
|
// whether a write needs a decision, and who resolves it, from roles/*.json as the business loader
|
|
// reads them (lead decision 113). A version 1 file, the researcher's, grants nothing.
|
|
// Neither expectation is written out here, so a change to the table, a role file or the verbs
|
|
// that leaves them disagreeing fails this file.
|
|
const table = loadFields();
|
|
const ROLES = fileURLToPath(new URL('../../../roles/', import.meta.url));
|
|
const authority = (r) => loadRole(ROLES, r).authority;
|
|
const raw = () => JSON.parse(readFileSync(FIELDS_FILE, 'utf8'));
|
|
const tableError = (message) => (e) => e instanceof FieldTableError && (message === undefined || e.message === message);
|
|
|
|
test('the committed table loads frozen; its verbs are the broker task verbs', () => {
|
|
assert.deepEqual(Object.keys(table.verbs).sort(), [...TASK_VERBS].sort());
|
|
assert.ok(Object.isFrozen(table) && Object.isFrozen(table.fields[0].writes[0].calls));
|
|
assert.deepEqual(checkFields(raw()), raw());
|
|
});
|
|
|
|
test('the schema refuses any other shape', () => {
|
|
const bad = {
|
|
'an unknown top key': (t) => (t.writers = {}),
|
|
'no source': (t) => delete t.source,
|
|
'a blank source': (t) => (t.source = ' '),
|
|
'a source that is not a string': (t) => (t.source = 1),
|
|
'a source over 256 characters': (t) => (t.source = 'x'.repeat(257)),
|
|
'a task verb missing': (t) => delete t.verbs['task.close'],
|
|
'a task verb missing with the field it writes': (t) => {
|
|
delete t.verbs['task.close'];
|
|
t.fields = t.fields.filter((f) => f.field !== 'done');
|
|
},
|
|
'a verb the broker lacks': (t) => (t.verbs['task.delete'] = 'pm'),
|
|
'an unknown writer': (t) => (t.verbs['task.close'] = 'cto'),
|
|
'no fields': (t) => (t.fields = []),
|
|
'a field twice': (t) => t.fields.push(structuredClone(t.fields[0])),
|
|
'a field name with a space': (t) => (t.fields[0].field = 'due date'),
|
|
'an unknown field key': (t) => (t.fields[0].writer = 'pm'),
|
|
'no label': (t) => delete t.fields[0].label,
|
|
'a label over 80 characters': (t) => (t.fields[0].label = 'x'.repeat(81)),
|
|
'a note over 256 characters': (t) => (t.fields[0].note = 'x'.repeat(257)),
|
|
'a blank note': (t) => (t.fields[0].note = ' '),
|
|
'no writes': (t) => (t.fields[0].writes = []),
|
|
'a write to an unknown verb': (t) => (t.fields[0].writes[0].verb = 'task.delete'),
|
|
'one verb twice in a field': (t) => t.fields[0].writes.push(structuredClone(t.fields[0].writes[0])),
|
|
'an unknown write key': (t) => (t.fields[0].writes[0].when = 'at create'),
|
|
'no calls': (t) => (t.fields[0].writes[0].calls = []),
|
|
'a read': (t) => (t.fields[0].writes[0].calls = ['GET /tasks/{t}']),
|
|
'a call with a query': (t) => (t.fields[0].writes[0].calls = ['PATCH /tasks/{t}?x=1']),
|
|
'one call twice': (t) => t.fields[0].writes[0].calls.push(t.fields[0].writes[0].calls[0]),
|
|
'a verb that writes no field': (t) => (t.fields = t.fields.filter((f) => f.field !== 'done')),
|
|
};
|
|
for (const [what, edit] of Object.entries(bad)) {
|
|
const t = raw();
|
|
edit(t);
|
|
assert.throws(() => checkFields(t), tableError(), what);
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), 'fields-'));
|
|
try {
|
|
writeFileSync(join(dir, 'cut.json'), '{"source":');
|
|
assert.throws(() => loadFields(join(dir, 'cut.json')), tableError('fields.json: not JSON'));
|
|
assert.throws(() => loadFields(join(dir, 'absent.json')), tableError('fields.json: ENOENT'));
|
|
const t = raw();
|
|
t.verbs['task.close'] = 'any';
|
|
writeFileSync(join(dir, 'wide.json'), JSON.stringify(t));
|
|
assert.doesNotThrow(() => loadFields(join(dir, 'wide.json')), 'a wider writer is still a valid shape; the next test refuses it');
|
|
t.verbs['task.close'] = 'reviewer';
|
|
writeFileSync(join(dir, 'odd.json'), JSON.stringify(t));
|
|
assert.throws(() => loadFields(join(dir, 'odd.json')), tableError('verbs.task.close: writer must be pm, assigned, any'));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// A Vikunja write, under /api/v2, as the table names it, and the fields it writes. A bucket move
|
|
// writes `done` when it moves into the done bucket and the bucket otherwise.
|
|
const CALLS = [
|
|
[/^\/projects\/\d+\/tasks$/, '/projects/{p}/tasks', (r) => Object.keys(r.body)],
|
|
[/^\/tasks\/\d+$/, '/tasks/{t}', (r) => Object.keys(r.body)],
|
|
[/^\/projects\/\d+\/views\/\d+\/buckets\/(\d+)\/tasks$/, '/projects/{p}/views/{k}/buckets/{b}/tasks', (r, m, done) => [Number(m[1]) === done ? 'done' : 'bucket']],
|
|
[/^\/tasks\/\d+\/assignees$/, '/tasks/{t}/assignees', () => ['assignees']],
|
|
[/^\/tasks\/\d+\/assignees\/\d+$/, '/tasks/{t}/assignees/{u}', () => ['assignees']],
|
|
[/^\/tasks\/\d+\/labels$/, '/tasks/{t}/labels', () => ['labels']],
|
|
[/^\/tasks\/\d+\/labels\/\d+$/, '/tasks/{t}/labels/{l}', () => ['labels']],
|
|
[/^\/tasks\/\d+\/relations$/, '/tasks/{t}/relations', () => ['relations']],
|
|
[/^\/tasks\/\d+\/relations\/[a-z]+\/\d+$/, '/tasks/{t}/relations/{kind}/{other}', () => ['relations']],
|
|
[/^\/tasks\/\d+\/comments$/, '/tasks/{t}/comments', () => ['comments']],
|
|
];
|
|
function writes(r, done) {
|
|
for (const [re, path, fields] of CALLS) {
|
|
const m = re.exec(r.path);
|
|
if (m) return fields(r, m, done).map((f) => `${f} ${r.method} ${path}`);
|
|
}
|
|
assert.fail(`a write the table has no call for: ${r.method} ${r.path}`);
|
|
}
|
|
const expected = (verb) =>
|
|
table.fields.flatMap((f) => f.writes.filter((w) => w.verb === verb).flatMap((w) => w.calls.map((c) => `${f.field} ${c}`))).sort();
|
|
|
|
test('each verb refuses, asks for a decision and writes as the table and the role files say', async (t) => {
|
|
const fake = new FakeVikunja();
|
|
const sent = [];
|
|
const fetch = async (url, init = {}) => {
|
|
if ((init.method ?? 'GET') !== 'GET')
|
|
sent.push({ method: init.method, path: new URL(url).pathname.slice('/api/v2'.length), body: init.body === undefined ? undefined : JSON.parse(init.body) });
|
|
return fake.fetch(url, init);
|
|
};
|
|
const dir = mkdtempSync(join(tmpdir(), 'fields-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
// The role files' authority, and a researcher seat.
|
|
const w = await world(t, {
|
|
fake,
|
|
fetch,
|
|
business: (b, { owner, project }) => {
|
|
for (const r of ['pm', 'coder', 'reviewer', 'cto']) b.roles[r].authority = authority(r);
|
|
const bot = fake.user('bot-demo-researcher', { owner });
|
|
fake.share(project, bot, 1);
|
|
const file = join(dir, 'researcher.token');
|
|
writeFileSync(file, fake.token(bot, SCOPES.worker) + '\n');
|
|
chmodSync(file, 0o600);
|
|
b.roles.researcher = {
|
|
definition: 'researcher',
|
|
authority: authority('researcher'),
|
|
tracker: { bot: 'bot-demo-researcher', botId: bot },
|
|
credentials: { vikunja: { file, expires: '2099-01-01' } },
|
|
};
|
|
},
|
|
});
|
|
assert.deepEqual(w.businesses.demo.roles.researcher.authority, { withinRole: [], crossRole: [] });
|
|
await w.adapter.start('demo');
|
|
assert.equal(w.adapter.status()[0].state, 'ready');
|
|
const ROLE_NAMES = ['pm', 'coder', 'reviewer', 'researcher'];
|
|
const caps = Object.fromEntries(ROLE_NAMES.map((r) => [r, w.agent(r)]));
|
|
const cto = w.agent('cto');
|
|
const botId = (r) => w.businesses.demo.roles[r].tracker.botId;
|
|
const done = w.installed.buckets.done;
|
|
const seen = Object.fromEntries(TASK_VERBS.map((v) => [v, new Set()]));
|
|
const code = (p) => p.then(() => 'ok', (e) => e.code ?? e.message);
|
|
// One call, checked layer by layer. Returns the verb's answer, or null when it refused.
|
|
async function attempt(role, verb, args) {
|
|
const writer = table.verbs[verb];
|
|
const before = sent.length;
|
|
const refused = async (want) => {
|
|
assert.equal(await code(w.call(caps[role], verb, args)), want, `${role} ${verb}`);
|
|
assert.equal(sent.length, before, `${role} ${verb} refused but wrote`);
|
|
return null;
|
|
};
|
|
if (writer === 'pm' && w.businesses.demo.roles[role].definition !== 'pm') return refused('field-writer');
|
|
if (writer === 'assigned' && !fake.task(Number(args.task_ref.split('/')[1])).assignees.includes(botId(role))) return refused('not-assigned');
|
|
const a = authority(role);
|
|
let decision;
|
|
if (!a.withinRole.includes(verb)) {
|
|
await refused('decision-required');
|
|
const d = w.broker.request(caps[role], {
|
|
verb: 'decision.raise',
|
|
args: {
|
|
action: verb,
|
|
domain: 'technical',
|
|
target: args.task_ref ?? null,
|
|
...(args.task_ref ? { task_ref: args.task_ref } : {}),
|
|
question: `${role} ${verb}?`,
|
|
options: [
|
|
{ key: 'yes', text: 'yes' },
|
|
{ key: 'no', text: 'no' },
|
|
],
|
|
recommendation: 'yes',
|
|
blocking: false,
|
|
},
|
|
});
|
|
// Cross-role goes to the technical arbiter; anything the role file doesn't list, to the human.
|
|
const route = a.crossRole.includes(verb) ? w.businesses.demo.arbiters.technical : 'human';
|
|
assert.equal(w.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, route, `${role} ${verb} route`);
|
|
w.broker.request(route === 'human' ? w.human : cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
|
decision = d.id;
|
|
}
|
|
const r = await w.call(caps[role], verb, decision ? { ...args, decision } : args);
|
|
assert.ok(sent.length > before, `${role} ${verb} wrote nothing`);
|
|
for (const x of sent.slice(before)) for (const s of writes(x, done)) seen[verb].add(s);
|
|
return r;
|
|
}
|
|
const others = ROLE_NAMES.filter((r) => r !== 'pm');
|
|
const create = { title: 'b', description: 'what', due_date: '2026-11-01T00:00:00.000Z', priority: 2, labels: [w.label] };
|
|
for (const r of others) await attempt(r, 'task.create', { ...create, request: w.instruction(), requirement: 'REQ-TASK-1' });
|
|
const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
|
const relation = { kind: 'related', task_ref: a.task_ref };
|
|
const { task_ref } = await attempt('pm', 'task.create', { ...create, relations: [relation], request: w.instruction(), requirement: 'REQ-TASK-1' });
|
|
for (const r of others) {
|
|
await attempt(r, 'task.assign', { task_ref, role: 'coder' });
|
|
await attempt(r, 'task.reassign', { task_ref, role: 'coder' });
|
|
await attempt(r, 'task.schedule', { task_ref, due_date: null });
|
|
await attempt(r, 'task.priority.change', { task_ref, priority: 4 });
|
|
await attempt(r, 'task.close', { task_ref, verdict: 'row 61 r1' });
|
|
}
|
|
for (const r of ROLE_NAMES) await attempt(r, 'task.update.assigned', { task_ref, percent_done: 0.1 });
|
|
await attempt('pm', 'task.assign', { task_ref, role: 'coder' });
|
|
await attempt('pm', 'task.reassign', { task_ref, role: 'reviewer' });
|
|
await attempt('pm', 'task.schedule', { task_ref, due_date: null, labels: { remove: [w.label] }, relations: { remove: [relation] } });
|
|
await attempt('pm', 'task.schedule', { task_ref, labels: { add: [w.label] }, relations: { add: [relation] } });
|
|
await attempt('pm', 'task.priority.change', { task_ref, priority: 4 });
|
|
for (const [i, r] of ROLE_NAMES.entries()) await attempt(r, 'task.scope.change', { task_ref, title: `b${i}`, description: `what ${i}` });
|
|
for (const r of ['pm', 'coder', 'researcher']) await attempt(r, 'task.update.assigned', { task_ref, state: 'in-progress' });
|
|
await attempt('reviewer', 'task.update.assigned', { task_ref, percent_done: 0.5, state: 'in-progress', comment: 'started' });
|
|
// People add the coder and the researcher in Vikunja; each may then write as the assigned role.
|
|
const id = Number(task_ref.split('/')[1]);
|
|
for (const r of ['coder', 'researcher']) assert.equal((await w.ui('POST', `/tasks/${id}/assignees`, { user_id: botId(r) })).status, 201);
|
|
await attempt('coder', 'task.update.assigned', { task_ref, percent_done: 0.7 });
|
|
await attempt('researcher', 'task.update.assigned', { task_ref, state: 'in-review' });
|
|
await attempt('pm', 'task.close', { task_ref, verdict: 'row 61 r1' });
|
|
assert.equal(fake.task(id).done, true);
|
|
for (const v of TASK_VERBS) assert.deepEqual([...seen[v]].sort(), expected(v), v);
|
|
});
|