tasks, bus, harness, seat: the approved requirement ids as data (#1584)
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]>
This commit is contained in:
@@ -133,6 +133,17 @@ decision isn't in it. That comes from the role's authority in
|
||||
`tests/fields.test.mjs` walks every verb against the fake and checks
|
||||
both.
|
||||
|
||||
`requirements.json` holds the approved PRD's requirement ids as data
|
||||
(row 90, #1584): the PRD's path and version, and each id with a one-line
|
||||
title, in the PRD's order. The PRD (`docs/prd/mosaic-stack.md`) stays
|
||||
the reviewed source. `tests/requirements.test.mjs` reads its
|
||||
`**REQ-...**` bullets and its status line, and fails unless the list
|
||||
holds the same ids in the same order at the same version. A later PRD
|
||||
version is a new list in a reviewed commit. `loadRequirements()`
|
||||
(`src/requirements.mjs`) checks the file's shape, refuses any other and
|
||||
returns it frozen; the adapter loads it when it starts, and the bus
|
||||
loads it for its `requirements` read. Neither starts on a bad file.
|
||||
|
||||
A write that answers 5xx or doesn't answer is never retried. The adapter
|
||||
reads the task again. If the write took effect, it continues. If not,
|
||||
or the re-read fails too, the verb refuses with `write-uncertain`. A
|
||||
@@ -186,12 +197,15 @@ own write back as a person's edit.
|
||||
Details that aren't obvious from the table:
|
||||
|
||||
- `task.create` needs `request`, the id of a `human.input` event of this
|
||||
business (`request-not-found` otherwise), and a `requirement` like
|
||||
`REQ-TASK-3`. `project` is the tracker project id to create the task
|
||||
in, the root when left out. Any other project than an installed one of
|
||||
the tree refuses with `project-not-installed`, and a value that isn't a
|
||||
positive integer with `invalid-request`. The decision a create may
|
||||
need is bound to no target, so it doesn't name the project.
|
||||
business (`request-not-found` otherwise), and a `requirement` from
|
||||
`requirements.json`, like `REQ-TASK-3`. An id in the right form that
|
||||
the list doesn't hold refuses `unknown-requirement`. The `request` is
|
||||
the `request` field `message.receive` gives a human's message, not the
|
||||
message's own id. `project` is the tracker project id to create the
|
||||
task in, the root when left out. Any other project than an installed
|
||||
one of the tree refuses with `project-not-installed`, and a value that
|
||||
isn't a positive integer with `invalid-request`. The decision a create
|
||||
may need is bound to no target, so it doesn't name the project.
|
||||
`task.created` is recorded even when a label or relation write after
|
||||
the create fails. The task exists, and the verb then refuses with that
|
||||
write's code.
|
||||
@@ -376,7 +390,7 @@ on a live read with the sync key, and a switch is a follow-up row.
|
||||
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
|
||||
| `project-not-installed` | the project is watch-only, or not an installed project of the tree |
|
||||
| `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb |
|
||||
| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong |
|
||||
| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `unknown-requirement`, `invalid-request` | the call itself is wrong |
|
||||
|
||||
Startup adds `credential-unavailable`, `credential-expired`,
|
||||
`tracker-version`, `tracker-project`, `tracker-install`,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.0",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance; a claim locks and logs; revoking a holder is gated" },
|
||||
{ "id": "REQ-ROLE-3", "title": "A user-written business file declares instances, arbiters and credential references" },
|
||||
{ "id": "REQ-ROLE-4", "title": "The first business is Mosaic Stack: PM, CTO, coder and reviewer" },
|
||||
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
|
||||
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
|
||||
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
|
||||
{ "id": "REQ-CRED-2", "title": "A session that finds only founder credentials stops" },
|
||||
{ "id": "REQ-DEC-1", "title": "Every decision is an append-only record: raiser, class, options, resolver, times" },
|
||||
{ "id": "REQ-DEC-2", "title": "Decisions route by class: logged, to the domain arbiter, or to the human" },
|
||||
{ "id": "REQ-DEC-3", "title": "Human resolutions come only from a mosaic CLI session outside any agent run" },
|
||||
{ "id": "REQ-DEC-4", "title": "A blocking gated decision reaches Jason at once; the rest go in a daily digest" },
|
||||
{ "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder" },
|
||||
{ "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" },
|
||||
{ "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" },
|
||||
{ "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" },
|
||||
{ "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" },
|
||||
{ "id": "REQ-CLI-1", "title": "mosaic is the front door: talk to the PM, the inbox, tasks, agents and trails" },
|
||||
{ "id": "REQ-CLI-2", "title": "The PM session mosaic talks to is launched and owned by the stack, not T3" },
|
||||
{ "id": "REQ-WEB-1", "title": "The WebUI shows the same data as the CLI" },
|
||||
{ "id": "REQ-LAUNCH-1", "title": "The PM launches role sessions within business-file limits, logged and revocable" },
|
||||
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
|
||||
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
|
||||
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
// Row 90 (#1584): the approved PRD's requirement ids as data, in ../requirements.json. The PRD
|
||||
// (docs/prd/mosaic-stack.md) stays the reviewed source; tests/requirements.test.mjs fails unless
|
||||
// the list holds its **REQ-...** ids in its order. A later PRD version is a new list in a reviewed
|
||||
// commit. task.create refuses an id the list doesn't hold, and the broker's `requirements` verb
|
||||
// returns the list to the sessions that create tasks. This module imports nothing from the bus,
|
||||
// so the broker can load it. This function is the file's schema: it refuses any other shape.
|
||||
export const REQUIREMENTS_FILE = new URL('../requirements.json', import.meta.url);
|
||||
export const REQUIREMENT_ID = /^REQ-[A-Z]+-[1-9][0-9]*$/;
|
||||
export class RequirementsError extends Error {}
|
||||
const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
const str = (x, max) => typeof x === 'string' && x.trim() !== '' && x.length <= max;
|
||||
function exact(x, where, allowed) {
|
||||
if (!plain(x)) throw new RequirementsError(`${where}: not an object`);
|
||||
for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new RequirementsError(`${where}: unknown key ${k}`);
|
||||
for (const k of allowed) if (!Object.hasOwn(x, k)) throw new RequirementsError(`${where}: missing ${k}`);
|
||||
}
|
||||
export function checkRequirements(list) {
|
||||
exact(list, 'list', ['prd', 'version', 'requirements']);
|
||||
if (!str(list.prd, 256) || !/^docs\/prd\/[a-z0-9-]+\.md$/.test(list.prd))
|
||||
throw new RequirementsError('prd: not a docs/prd/*.md path');
|
||||
if (typeof list.version !== 'string' || !/^[1-9][0-9]*\.[0-9]+$/.test(list.version))
|
||||
throw new RequirementsError('version: not a version such as 1.0');
|
||||
if (!Array.isArray(list.requirements) || !list.requirements.length)
|
||||
throw new RequirementsError('requirements: not a nonempty list');
|
||||
const seen = new Set();
|
||||
for (const [i, r] of list.requirements.entries()) {
|
||||
const at = `requirements[${i}]`;
|
||||
exact(r, at, ['id', 'title']);
|
||||
if (typeof r.id !== 'string' || !REQUIREMENT_ID.test(r.id)) throw new RequirementsError(`${at}.id: not a requirement id`);
|
||||
if (seen.has(r.id)) throw new RequirementsError(`${at}.id: ${r.id} twice`);
|
||||
seen.add(r.id);
|
||||
if (!str(r.title, 120) || r.title.includes('\n')) throw new RequirementsError(`${at}.title: not one line of 120 characters or fewer`);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
function freeze(x) {
|
||||
if (x && typeof x === 'object') {
|
||||
for (const v of Object.values(x)) freeze(v);
|
||||
Object.freeze(x);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
// The committed list, checked and frozen. A file that fails the check refuses to load.
|
||||
export function loadRequirements(file = REQUIREMENTS_FILE) {
|
||||
let list;
|
||||
try {
|
||||
list = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
throw new RequirementsError(e instanceof SyntaxError ? 'requirements.json: not JSON' : `requirements.json: ${e.code ?? 'unreadable'}`);
|
||||
}
|
||||
return freeze(checkRequirements(list));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal } from './vikunja.mjs';
|
||||
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
|
||||
import { board, normal } from './sync.mjs';
|
||||
import { loadRequirements } from './requirements.mjs';
|
||||
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
|
||||
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
|
||||
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
|
||||
@@ -64,6 +65,8 @@ const sorted = (a) => [...a].sort((x, y) => x - y);
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
export function verbs(ctx) {
|
||||
const fail = (code) => refuse(code);
|
||||
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start.
|
||||
const requirements = new Set(loadRequirements().requirements.map((r) => r.id));
|
||||
const label = (id) => {
|
||||
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
|
||||
if (!ctx.labels.has(id)) fail('label-not-allowed');
|
||||
@@ -215,6 +218,7 @@ export function verbs(ctx) {
|
||||
plan.request = text(args.request, 64, 1);
|
||||
plan.requirement = text(args.requirement, 64);
|
||||
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
|
||||
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
|
||||
return plan;
|
||||
}
|
||||
case 'task.assign':
|
||||
|
||||
@@ -181,9 +181,9 @@ test('close waits for a running verb and refuses one that has not started', asyn
|
||||
const pm = w.agent('pm');
|
||||
let release;
|
||||
hold = new Promise((ok) => (release = ok));
|
||||
const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
await reached;
|
||||
const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
let done = false;
|
||||
const closing = w.adapter.close().then(() => (done = true));
|
||||
await sleep(20);
|
||||
@@ -193,7 +193,7 @@ test('close waits for a running verb and refuses one that has not started', asyn
|
||||
assert.ok((await running).task_ref);
|
||||
await assert.rejects(queued, (e) => e.code === 'tracker-closed');
|
||||
await assert.rejects(
|
||||
w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-S-1' }),
|
||||
w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-TASK-1' }),
|
||||
(e) => e.code === 'tracker-closed',
|
||||
);
|
||||
assert.equal(w.events('task.created').length, 1);
|
||||
|
||||
@@ -187,10 +187,10 @@ test('each verb refuses, asks for a decision and writes as the table and the rol
|
||||
}
|
||||
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-S-1' });
|
||||
const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
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-S-1' });
|
||||
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' });
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadRequirements, checkRequirements, RequirementsError, REQUIREMENTS_FILE } from '../src/requirements.mjs';
|
||||
// Row 90 (#1584): requirements.json against the approved PRD it names. The PRD's **REQ-...**
|
||||
// bullets are the source; the list must hold the same ids in the same order, and the PRD's
|
||||
// status line must give the list's version.
|
||||
const list = loadRequirements();
|
||||
const raw = () => JSON.parse(readFileSync(REQUIREMENTS_FILE, 'utf8'));
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const listError = (message) => (e) => e instanceof RequirementsError && (message === undefined || e.message === message);
|
||||
|
||||
test("the list holds the PRD's requirement ids, in its order, at its version", () => {
|
||||
const prd = readFileSync(join(REPO, list.prd), 'utf8');
|
||||
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
|
||||
// Any other mention of a bold id would be a bullet this pattern missed.
|
||||
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
|
||||
assert.equal(ids.length, 24);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.0']);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
assert.ok(Object.isFrozen(list) && Object.isFrozen(list.requirements) && Object.isFrozen(list.requirements[0]));
|
||||
assert.deepEqual(checkRequirements(raw()), raw());
|
||||
assert.deepEqual(loadRequirements(), list);
|
||||
});
|
||||
|
||||
test('the schema refuses any other shape', () => {
|
||||
const bad = {
|
||||
'an unknown top key': (l) => (l.source = 'x'),
|
||||
'no prd': (l) => delete l.prd,
|
||||
'a prd outside docs/prd': (l) => (l.prd = 'docs/plans/x.md'),
|
||||
'a prd that is not markdown': (l) => (l.prd = 'docs/prd/mosaic-stack.txt'),
|
||||
'a prd with a parent step': (l) => (l.prd = 'docs/prd/../x.md'),
|
||||
'no version': (l) => delete l.version,
|
||||
'a numeric version': (l) => (l.version = 1.5),
|
||||
'a version with no minor': (l) => (l.version = '1'),
|
||||
'a version 0': (l) => (l.version = '0.4'),
|
||||
'no requirements': (l) => delete l.requirements,
|
||||
'an empty list': (l) => (l.requirements = []),
|
||||
'requirements not a list': (l) => (l.requirements = {}),
|
||||
'an entry not an object': (l) => (l.requirements[0] = 'REQ-ROLE-1'),
|
||||
'an unknown entry key': (l) => (l.requirements[0].note = 'x'),
|
||||
'no id': (l) => delete l.requirements[0].id,
|
||||
'no title': (l) => delete l.requirements[0].title,
|
||||
'an id in lower case': (l) => (l.requirements[0].id = 'REQ-role-1'),
|
||||
'an id numbered 0': (l) => (l.requirements[0].id = 'REQ-ROLE-0'),
|
||||
'an id with a suffix': (l) => (l.requirements[0].id = 'REQ-ROLE-1a'),
|
||||
'an id twice': (l) => (l.requirements[1].id = l.requirements[0].id),
|
||||
'a blank title': (l) => (l.requirements[0].title = ' '),
|
||||
'a title over 120 characters': (l) => (l.requirements[0].title = 'x'.repeat(121)),
|
||||
'a title of two lines': (l) => (l.requirements[0].title = 'a\nb'),
|
||||
'a title that is not a string': (l) => (l.requirements[0].title = 1),
|
||||
};
|
||||
for (const x of [[], null, 'list']) assert.throws(() => checkRequirements(x), listError('list: not an object'));
|
||||
for (const [what, edit] of Object.entries(bad)) {
|
||||
const l = raw();
|
||||
edit(l);
|
||||
assert.throws(() => checkRequirements(l), listError(), what);
|
||||
}
|
||||
const ok = raw();
|
||||
ok.requirements[0].title = 'x'.repeat(120);
|
||||
assert.equal(checkRequirements(ok), ok);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'requirements-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'cut.json'), '{"prd":');
|
||||
assert.throws(() => loadRequirements(join(dir, 'cut.json')), listError('requirements.json: not JSON'));
|
||||
assert.throws(() => loadRequirements(join(dir, 'absent.json')), listError('requirements.json: ENOENT'));
|
||||
const twice = raw();
|
||||
twice.requirements.push(structuredClone(twice.requirements[0]));
|
||||
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[24].id: REQ-ROLE-1 twice'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -113,11 +113,11 @@ test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 se
|
||||
await w.adapter.start('demo');
|
||||
const pm = w.agent('pm'),
|
||||
coder = w.agent('coder');
|
||||
const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
const b = await w.call(pm, 'task.create', {
|
||||
title: 'b',
|
||||
request: w.instruction(),
|
||||
requirement: 'REQ-S-1',
|
||||
requirement: 'REQ-TASK-1',
|
||||
labels: [w.label],
|
||||
relations: [{ kind: 'blocked', task_ref: a.task_ref }],
|
||||
});
|
||||
@@ -201,7 +201,7 @@ test('the client works against the fake over real HTTP with the platform fetch',
|
||||
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
|
||||
await w.adapter.start('demo');
|
||||
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
|
||||
const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-S-1' });
|
||||
const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
assert.equal(r.task_ref, `vikunja:${w.project}/1`);
|
||||
await w.ui('PATCH', '/tasks/1', { title: 'edited' });
|
||||
await sleep(20);
|
||||
|
||||
@@ -8,7 +8,7 @@ const ready = async (t, edit) => {
|
||||
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' });
|
||||
w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-TASK-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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { world, sleep, SCOPES } from './world.mjs';
|
||||
import { FakeVikunja } from '../src/fake.mjs';
|
||||
import { loadRequirements } from '../src/requirements.mjs';
|
||||
const ready = async (t, edit) => {
|
||||
const w = await world(t, edit);
|
||||
await w.adapter.start('demo');
|
||||
@@ -13,12 +14,12 @@ const refusedWith = (code) => (e) => e.code === code;
|
||||
// A pm-created task, returned with its ref and digest.
|
||||
const task = async (w, args = {}) => {
|
||||
const pm = w.caps.pm ?? w.agent('pm');
|
||||
return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args });
|
||||
return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-TASK-1', ...args });
|
||||
};
|
||||
test('task.create needs a recorded human request and a requirement id', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' };
|
||||
const args = { title: 'x', request: w.instruction(), requirement: 'REQ-TASK-1' };
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found'));
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request'));
|
||||
await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request'));
|
||||
@@ -28,7 +29,7 @@ test('task.create needs a recorded human request and a requirement id', async (t
|
||||
assert.equal(r.task_ref, `vikunja:${w.project}/1`);
|
||||
const created = w.events('task.created');
|
||||
assert.equal(created.length, 1);
|
||||
assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' });
|
||||
assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-TASK-1' });
|
||||
assert.equal(created[0].actor_role, 'pm');
|
||||
const [s] = w.snapshots(r.task_ref);
|
||||
assert.equal(s.source, 'self');
|
||||
@@ -50,6 +51,35 @@ test('task.create needs a recorded human request and a requirement id', async (t
|
||||
);
|
||||
assert.equal(allowed(w, 'task.create').length, 1);
|
||||
});
|
||||
// Row 90 (#1584): the ids are the approved PRD's, from requirements.json.
|
||||
test('task.create takes only an approved requirement id', async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const { requirements } = loadRequirements();
|
||||
const args = { title: 'x', request: w.instruction() };
|
||||
// REQ-QA-1 is the id P19 #229 and #230 were created under: the right form, not in the PRD.
|
||||
for (const requirement of ['REQ-QA-1', 'REQ-TASK-5', 'REQ-S-1'])
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, requirement }), refusedWith('unknown-requirement'), requirement);
|
||||
// The form is checked first, as before.
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'REQ-task-1' }), refusedWith('invalid-request'));
|
||||
assert.equal(allowed(w, 'task.create').length, 0);
|
||||
assert.equal(w.events('task.created').length, 0);
|
||||
for (const { id } of [requirements[0], requirements.at(-1)]) await w.call(pm, 'task.create', { ...args, requirement: id });
|
||||
assert.deepEqual(w.events('task.created').map((e) => e.body.requirement), [requirements[0].id, requirements.at(-1).id]);
|
||||
});
|
||||
test("task.create cites the request a received message carries, not the message's id", async (t) => {
|
||||
const w = await ready(t);
|
||||
const pm = w.agent('pm');
|
||||
const sent = w.broker.request(w.human, { verb: 'message.send', args: { to: 'pm', body: 'build the thing' } });
|
||||
const [m] = w.broker.request(pm, { verb: 'message.receive' });
|
||||
assert.equal(m.id, sent.id);
|
||||
assert.equal(m.request, sent.request);
|
||||
assert.notEqual(m.request, m.id);
|
||||
const args = { title: 'the thing', requirement: 'REQ-TASK-1' };
|
||||
await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found'));
|
||||
const r = await w.call(pm, 'task.create', { ...args, request: m.request });
|
||||
assert.deepEqual(w.events('task.created').map((e) => [e.subject, e.body]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]);
|
||||
});
|
||||
test('only labels named in the business file can be written', async (t) => {
|
||||
const w = await ready(t);
|
||||
// The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it.
|
||||
|
||||
Reference in New Issue
Block a user