darkwing: row 61 (#1550) packet, round 1

The task field-writer table as data: build.patch (12 files on 4f8ac1e7),
candidate manifest, gate summary (all green), mutants 33/33 killed.
The candidate is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 13:43:39 -05:00
co-authored by Claude Opus 5.5
parent 4918d410f3
commit f39023818f
8 changed files with 1018 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
# Row 61, the task field-writer table as data, round 1 (Darkwing)
Gitea #1550, Vikunja #208. Brief:
`docs/plans/2026-10-10_design-implementation.md` § "Task field-writer
table as data". Lead decision 113 settles the code and prose
disagreements I raised, and queue rev 672 adds the field table section of
`docs/plans/2026-10-04_slice-1.md` to the files owned. Base `4f8ac1e7`;
nothing from there to the packet commit touches the candidate's paths.
The candidate is `build.patch`, 12 files, applied with `git apply --index`
on `4f8ac1e7`. `candidate-manifest.sha256` lists the 12 files after the
apply. Nothing here is committed to `refactor`.
## What ships
**The table, `packages/tasks/fields.json`.** It has two layers, as
decision 113 rules.
- `verbs` maps each of the 8 broker task verbs to the writer `verbs.mjs`
checks first: `pm` (the pm definition, `field-writer` otherwise),
`assigned` (a role whose bot is an assignee, `not-assigned` otherwise)
or `any` (no writer check). Only `task.scope.change` is `any`, and only
`task.update.assigned` is `assigned`.
- `fields` lists the 11 fields in the slice 1 table. Each field names the
verbs that write it and the Vikunja calls each verb makes for it, with
the same placeholders as the prose.
Whether a write also needs a resolved decision isn't in the file. It comes
from the role's authority in `roles/*.json`, which the broker already
reads, so a second copy could only drift.
**The schema is code.** `packages/tasks/src/fields.mjs` has
`checkFields`, which refuses any other shape: unknown or missing keys, a
writer outside the three, a verb set that isn't exactly the broker's
`TASK_VERBS`, a field twice, a verb twice in one field, a call that isn't
a Vikunja write, a call twice, and a verb that writes no field. Labels are
capped at 80 characters, the source and notes at 256. `loadFields()`
reads the committed file, checks it and freezes it. Its errors name
`fields.json`, never a path. The repository has no JSON Schema validator,
and `packages/business` checks its files the same way, so I followed that
instead of adding a dependency.
**The test, `packages/tasks/tests/fields.test.mjs`.** It reads both
expectations rather than writing them out. The writer layer comes from
the table, and authority from `roles/*.json` through `packages/business`
`loadRole`, so the researcher's version 1 file grants nothing. Then it
drives every task verb as pm, coder, reviewer and researcher against the
fake Vikunja, with the cto as the technical arbiter:
- a role the table's writer excludes gets `field-writer` or
`not-assigned` and sends no write;
- a role without the verb within-role gets `decision-required` and sends
no write; the test raises a decision, checks it routes to the technical
arbiter when the role lists the verb cross-role and to the human
otherwise, resolves it and retries;
- every write that goes out is mapped back to the fields it carries
(a `PATCH` by its body keys, a bucket move into `done` as `done`), and
at the end each verb's writes must equal the table's calls for that
verb.
A change to the table, a role file or `verbs.mjs` that leaves them
disagreeing fails this file. Two smaller tests check that the committed
table loads frozen with exactly the broker's verbs, and the schema cases.
**The inspector.** The task page (`#/tasks/<ref>`) has a "Who may write"
section before the trail, with one row per field: the label, its note,
and each verb with its writer in words. Its intro says the decision layer
comes from the role's authority on Business, and that edits made in
Vikunja aren't checked against this table. A table that fails to load
shows the read error in that section only; the rest of the page renders.
To get the table to the browser, `serve.mjs` has `GET /api/task-fields`,
and `reads.mjs` has `fieldTable(file)`, named so it doesn't read as the
`taskFields` digest in `packages/tasks`. The table is repository data, so
the route answers without `--business` or a system config and carries no
`at`. A table that fails its check is 503 `read-failed` with the loader's
message and no path; other methods are 405. `startServer` takes a
`fieldsFile` option for tests only. I count `serve.mjs` and `reads.mjs`
as part of "the webui Tasks inspector": the page can't read the table
without them. If reviewers read the files owned more narrowly, that's the
place to say so.
**The prose.** Only the field table section of the slice 1 plan changes,
with the five corrections from decision 113 and a paragraph saying where
the data lives. One wording differs from what I proposed: the priority
row says "a change after create needs a resolved decision (cross-role)",
because `task.create` is within-role for pm, so priority at create needs
none. The READMEs for `packages/tasks` and `packages/webui` describe the
file, the route and the section.
Out of scope and unchanged: who may write a field, `roles/*.json`, and
close not checking the verdict (Vikunja #218).
## This lands on the live Console
Since the 10-12 cutover the Console runs from the canonical checkout
(decision 109). Landing this changes `serve.mjs`, `reads.mjs` and
`public/bus.js` there, so Sage notifies Mos before it lands, and the
status and healthz checks follow. I don't start or restart the Console.
## Overlap with row 81
Dewey's row 81 (#1575, in review) also changes `serve.mjs`, `public/bus.js`
and `packages/webui/README.md`. The two patches touch different hunks. On
`4918d410` I applied both with `git apply --3way --index`, in each order,
and both went in cleanly both times. Whichever row lands second still needs
a new round, because its manifest names the other's lines.
## Tests
In `~/darkwing-scratch/r61/gate`, a detached worktree at `4f8ac1e7` with
`build.patch` applied by `git apply --index` and `node_modules` linked.
Node v26.8.1, run in order from 18:39:31Z to 18:42:55Z on 2026-10-12
(`gate-summary.txt`). Every one exits 0:
| Suite | Result |
|---|---|
| `packages/tasks` (`node --test tests/*.test.mjs`) | 74 pass, 0 fail |
| `packages/webui` (`node --test tests/`) | 82 pass, 0 fail |
| `test-auth` | 15 passed |
| `test-conductor` | 17 passed |
| `test-config` | 24 passed |
| `test-discord` | 66 passed |
| `test-extension-package` | 18 passed |
| `test-foundation` | 44 passed |
| `test-queue` | 150 node tests, 27 suite checks |
| `test-release` | 14 passed |
| `test-task` | 26 passed, run with `DOCKER_HOST=unix:///nonexistent`, so its 4 live checks skip |
Afterwards I removed the `gate_default` Docker network the gate
left behind, which had no containers.
## Mutants
`mutants.sh WORKTREE [browser|light|NAME,...]` makes 33 mutants, one site
each, runs the named test file in that package and restores the file from
a backup. The edits use `queue-59-review/r1/mut.mjs`. The light 30 ran to
18:39:18Z (`mutants-light.txt`), the 3 browser ones to 18:38:38Z
(`mutants-browser.txt`). All 33 are killed, and `build.patch` still hashes
the same in the worktree afterwards.
| Group | Mutants | Killed by |
|---|---|---|
| Schema (`fields.mjs`) | 15: unknown key, optional key, any writer string, field twice, any field name, unbounded label, any note, verb twice, any call, call twice, unused verb, verbs a subset, any source, no freeze, wrong not-JSON code | the schema cases; `no-freeze` by the load test |
| Table (`fields.json`) | 8: scope to pm, priority to any, update to any, close to assigned, reassign without its delete, create without the due date, comments by close, done by update | the verb walk; `done-to-bucket` by the schema |
| Code (`verbs.mjs`) | 3: priority without a writer check, scope as pm, the assignee check gone | the verb walk |
| Route and read | 4: any method, the route ignoring `fieldsFile`, a partial table on error, any message | `reads.test.mjs` |
| Section (`bus.js`) | 3: a throwing section, the two writers swapped, no note | `bus-browser.test.mjs` |
`done-to-bucket` moves the `done` write from `task.close` to
`task.update.assigned`. The verb walk never runs on it: the load refuses
first with "verbs.task.close: writes no field", which fails the whole
file. That's the schema doing its job, but it means the walk's own check
of the `done` mapping is only shown by `comments-by-close`.
An earlier light run left 4 survivors: `required-key-optional`,
`label-unbounded`, `verbs-subset` and `source-any`. I added six schema
cases: a blank source, a source that isn't a string, a 257-character
source, a task verb missing along with the field it writes, an
81-character label and a 257-character note. All 4 died
(`mutants-light-r2.txt`).
+723
View File
@@ -0,0 +1,723 @@
diff --git a/docs/plans/2026-10-04_slice-1.md b/docs/plans/2026-10-04_slice-1.md
index bba07726..b845fcdb 100644
--- a/docs/plans/2026-10-04_slice-1.md
+++ b/docs/plans/2026-10-04_slice-1.md
@@ -63,17 +63,26 @@ The title, description and comments rows were corrected to what the code
enforces on 2026-10-11 (lead decision 90). `message.send` is a bus
message and never writes a tracker comment.
+The title, description, due date, priority, assigned-role and `done` rows
+were corrected to what the code enforces on 2026-10-12 (lead decision
+113). Row 61 holds the table as data in `packages/tasks/fields.json`: the
+writer each verb checks (pm, the assigned role or any role) and the
+Vikunja calls it makes. Whether a write also needs a resolved decision
+comes from the role's authority in `roles/*.json`, and the row's test
+reads it from there.
+
| Field | Writer | Action | Vikunja call |
|---|---|---|---|
-| title, description | pm at create; afterwards a role whose authority lists `task.scope.change` (pm, cto and coder today, each cross-role), with a resolved decision | `task.create`, `task.scope.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
-| due date, priority | pm | `task.schedule`, `task.priority.change` | `PATCH /tasks/{t}` |
-| percent done | the assigned role | `task.update.assigned` | `PATCH /tasks/{t}` |
-| state (bucket) other than `done` | the assigned role | `task.update.assigned` | `PUT /projects/{p}/views/{k}/buckets/{b}/tasks` |
-| `done` | pm, after a review verdict | `task.close` | the same move, into the `done` bucket |
+| title, description | pm at create; afterwards any role through `task.scope.change`, with a resolved decision: from the arbiter for a role that lists it cross-role (pm, cto and coder today), from Jason for any other | `task.create`, `task.scope.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| due date | pm | `task.create`, `task.schedule` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| priority | pm; a change after create needs a resolved decision (cross-role) | `task.create`, `task.priority.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| percent done | the assigned role; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `PATCH /tasks/{t}` |
+| state (bucket) other than `done` | the assigned role; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `PUT /projects/{p}/views/{k}/buckets/{b}/tasks` |
+| `done` | pm, citing a review verdict (recorded, not checked) | `task.close` | the same move, into the `done` bucket |
| assignee | pm | `task.assign`, `task.reassign` | `POST /tasks/{t}/assignees`; `DELETE /tasks/{t}/assignees/{u}` |
| labels | pm | `task.create`, `task.schedule` | `POST /tasks/{t}/labels`; `DELETE /tasks/{t}/labels/{l}` |
| relations | pm | `task.create`, `task.schedule` | `POST /tasks/{t}/relations`; `DELETE /tasks/{t}/relations/{kind}/{other}` |
-| comments | the assigned role, append only | `task.update.assigned` | `POST /tasks/{t}/comments` |
+| comments | the assigned role, append only; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `POST /tasks/{t}/comments` |
A `PATCH` body carries only `title`, `description`, `due_date`,
`priority` and `percent_done`, and only the keys the action owns. Vikunja
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
index d47f5de6..ff675343 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -119,6 +119,16 @@ All eight go through the same steps:
that change something are sent.
7. A final read, then one self snapshot and its event.
+`fields.json` holds the slice 1 field table as data (row 61, lead
+decision 113): the writer each verb checks in step 1 or in
+`task.update.assigned` (pm, the assigned role or any role), and the
+fields each verb writes with their Vikunja calls. `loadFields()` checks
+its shape and refuses any other. Whether a write also needs a resolved
+decision isn't in it. That comes from the role's authority in
+`roles/*.json`, which `authorize` reads in step 5.
+`tests/fields.test.mjs` walks every verb against the fake and checks
+both.
+
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
diff --git a/packages/tasks/fields.json b/packages/tasks/fields.json
new file mode 100644
index 00000000..050ccc3e
--- /dev/null
+++ b/packages/tasks/fields.json
@@ -0,0 +1,93 @@
+{
+ "source": "docs/plans/2026-10-04_slice-1.md, Field table (lead decisions 90 and 113)",
+ "verbs": {
+ "task.create": "pm",
+ "task.assign": "pm",
+ "task.reassign": "pm",
+ "task.schedule": "pm",
+ "task.priority.change": "pm",
+ "task.scope.change": "any",
+ "task.update.assigned": "assigned",
+ "task.close": "pm"
+ },
+ "fields": [
+ {
+ "field": "title",
+ "label": "Title",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.scope.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "description",
+ "label": "Description",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.scope.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "due_date",
+ "label": "Due date",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.schedule", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "priority",
+ "label": "Priority",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.priority.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "percent_done",
+ "label": "Percent done",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["PATCH /tasks/{t}"] }]
+ },
+ {
+ "field": "bucket",
+ "label": "State (bucket) other than done",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]
+ },
+ {
+ "field": "done",
+ "label": "Done",
+ "note": "Cites a review verdict, which is recorded and not checked.",
+ "writes": [{ "verb": "task.close", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]
+ },
+ {
+ "field": "assignees",
+ "label": "Assignees",
+ "writes": [
+ { "verb": "task.assign", "calls": ["POST /tasks/{t}/assignees"] },
+ { "verb": "task.reassign", "calls": ["POST /tasks/{t}/assignees", "DELETE /tasks/{t}/assignees/{u}"] }
+ ]
+ },
+ {
+ "field": "labels",
+ "label": "Labels",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /tasks/{t}/labels"] },
+ { "verb": "task.schedule", "calls": ["POST /tasks/{t}/labels", "DELETE /tasks/{t}/labels/{l}"] }
+ ]
+ },
+ {
+ "field": "relations",
+ "label": "Relations",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /tasks/{t}/relations"] },
+ { "verb": "task.schedule", "calls": ["POST /tasks/{t}/relations", "DELETE /tasks/{t}/relations/{kind}/{other}"] }
+ ]
+ },
+ {
+ "field": "comments",
+ "label": "Comments",
+ "note": "Append only.",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["POST /tasks/{t}/comments"] }]
+ }
+ ]
+}
diff --git a/packages/tasks/src/fields.mjs b/packages/tasks/src/fields.mjs
new file mode 100644
index 00000000..15d8e4f4
--- /dev/null
+++ b/packages/tasks/src/fields.mjs
@@ -0,0 +1,76 @@
+import { readFileSync } from 'node:fs';
+import { TASK_VERBS } from '../../bus/src/broker.mjs';
+// Row 61 (#1550): the slice 1 field table as data, in ../fields.json. The prose in
+// docs/plans/2026-10-04_slice-1.md stays the reviewed source; tests/fields.test.mjs checks both
+// against what the verbs do. Two layers, as lead decision 113 rules:
+// - `verbs`: the writer verbs.mjs checks before anything else. `pm` is the pm definition
+// (field-writer otherwise), `assigned` a role whose bot is an assignee (not-assigned
+// otherwise), `any` no writer check.
+// - Whether a write also needs a resolved decision isn't here. It comes from the role's
+// authority in roles/*.json, which the broker reads.
+// Each field lists the verbs that write it and the Vikunja calls each verb makes for it. This
+// function is the file's schema: it refuses any other shape.
+export const FIELDS_FILE = new URL('../fields.json', import.meta.url);
+export const WRITERS = Object.freeze(['pm', 'assigned', 'any']);
+const CALL = /^(POST|PUT|PATCH|DELETE) (\/(?:[a-z_]+|\{[a-z]+\}))+$/;
+const FIELD = /^[a-z][a-z_]{0,63}$/;
+export class FieldTableError 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, required = allowed) {
+ if (!plain(x)) throw new FieldTableError(`${where}: not an object`);
+ for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new FieldTableError(`${where}: unknown key ${k}`);
+ for (const k of required) if (!Object.hasOwn(x, k)) throw new FieldTableError(`${where}: missing ${k}`);
+}
+function list(x, where) {
+ if (!Array.isArray(x) || !x.length) throw new FieldTableError(`${where}: not a nonempty list`);
+ return x;
+}
+export function checkFields(table, verbs = TASK_VERBS) {
+ exact(table, 'table', ['source', 'verbs', 'fields']);
+ if (!str(table.source, 256)) throw new FieldTableError('source: not a nonempty string');
+ exact(table.verbs, 'verbs', verbs);
+ for (const [v, w] of Object.entries(table.verbs))
+ if (!WRITERS.includes(w)) throw new FieldTableError(`verbs.${v}: writer must be ${WRITERS.join(', ')}`);
+ const seen = new Set(), used = new Set();
+ for (const [i, f] of list(table.fields, 'fields').entries()) {
+ const at = `fields[${i}]`;
+ exact(f, at, ['field', 'label', 'note', 'writes'], ['field', 'label', 'writes']);
+ if (typeof f.field !== 'string' || !FIELD.test(f.field)) throw new FieldTableError(`${at}.field: not a field name`);
+ if (seen.has(f.field)) throw new FieldTableError(`${at}.field: ${f.field} twice`);
+ seen.add(f.field);
+ if (!str(f.label, 80)) throw new FieldTableError(`${at}.label: not a nonempty string`);
+ if (f.note !== undefined && !str(f.note, 256)) throw new FieldTableError(`${at}.note: not a nonempty string`);
+ const mine = new Set();
+ for (const [j, w] of list(f.writes, `${at}.writes`).entries()) {
+ const wat = `${at}.writes[${j}]`;
+ exact(w, wat, ['verb', 'calls']);
+ if (!Object.hasOwn(table.verbs, w.verb)) throw new FieldTableError(`${wat}.verb: not a task verb`);
+ if (mine.has(w.verb)) throw new FieldTableError(`${wat}.verb: ${w.verb} twice`);
+ mine.add(w.verb);
+ used.add(w.verb);
+ const calls = list(w.calls, `${wat}.calls`);
+ for (const c of calls) if (typeof c !== 'string' || !CALL.test(c)) throw new FieldTableError(`${wat}.calls: not a Vikunja call`);
+ if (new Set(calls).size !== calls.length) throw new FieldTableError(`${wat}.calls: a call twice`);
+ }
+ }
+ for (const v of Object.keys(table.verbs)) if (!used.has(v)) throw new FieldTableError(`verbs.${v}: writes no field`);
+ return table;
+}
+function freeze(x) {
+ if (x && typeof x === 'object') {
+ for (const v of Object.values(x)) freeze(v);
+ Object.freeze(x);
+ }
+ return x;
+}
+// The committed table, checked and frozen. A file that fails the check refuses to load.
+export function loadFields(file = FIELDS_FILE) {
+ let table;
+ try {
+ table = JSON.parse(readFileSync(file, 'utf8'));
+ } catch (e) {
+ throw new FieldTableError(e instanceof SyntaxError ? 'fields.json: not JSON' : `fields.json: ${e.code ?? 'unreadable'}`);
+ }
+ return freeze(checkFields(table));
+}
diff --git a/packages/tasks/src/index.mjs b/packages/tasks/src/index.mjs
index ecd55e64..8164461e 100644
--- a/packages/tasks/src/index.mjs
+++ b/packages/tasks/src/index.mjs
@@ -4,3 +4,4 @@ export { tick, reconcile, WINDOW_MS } from './sync.mjs';
export { verbs } from './verbs.mjs';
export { client, apiRoot, outcome, refusal } from './vikunja.mjs';
export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs';
+export { loadFields, checkFields, FieldTableError, FIELDS_FILE, WRITERS } from './fields.mjs';
diff --git a/packages/tasks/tests/fields.test.mjs b/packages/tasks/tests/fields.test.mjs
new file mode 100644
index 00000000..597048bd
--- /dev/null
+++ b/packages/tasks/tests/fields.test.mjs
@@ -0,0 +1,218 @@
+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-S-1' });
+ const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-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' });
+ 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);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 4a7cf535..48aeb90b 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -267,6 +267,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
since the list has no trail. The task page reads the trail and tells a task
the stack never created from one changed in Vikunja after the stack wrote it.
A stale poll read is shown in the snapshots and never as current.
+- The task page's "Who may write" section lists each field with the verbs
+ that write it and the writer each verb checks: pm, the assigned role or any
+ role (row 61, lead decision 113). Whether a write also needs a resolved
+ decision is the role's authority, on the Business page. If the table read
+ fails, the section says why and the rest of the page still shows.
- The Tasks table has Requirement and Priority columns, from the `tasks` row's
`requirement` and `priority` (row 58, #1547). Either one shows "none" when
the row has none. A tombstone's page shows the priority of the last snapshot
@@ -434,6 +439,13 @@ for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
answers 200 even without a system config, so appearance always works; its
notifier part then says `not-configured`.
+GET `/api/task-fields` is row 61's read (#1550): the task field table,
+`packages/tasks/fields.json`, loaded and checked by `loadFields()` on each
+request. It is repository data, so it answers without `--business` or a
+system config. The answer is the table itself, with no `at`. A table that
+fails its check is refused whole with 503 `read-failed` and the loader's
+message, which names the file and never a path. Any other method is 405.
+
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index ffcc7cf5..a5e447a9 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -349,6 +349,20 @@
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`;
}
+ // Row 61 (#1550, lead decision 113): who may write each field, from the field table
+ // (packages/tasks/fields.json, GET /api/task-fields). The table gives the writer each verb
+ // checks; whether a write also needs a decision is each role's authority, on Business. A
+ // failed read leaves the rest of the task page as it is and says why here.
+ const WRITER = { pm: 'pm', assigned: 'the assigned role', any: 'any role' };
+ async function writers(get) {
+ let doc;
+ try { doc = await get.api('task-fields'); } catch (err) { return `<p class="small">${why(err)}</p>`; }
+ const fields = Array.isArray(doc.fields) ? doc.fields : [], verbs = doc.verbs && typeof doc.verbs === 'object' ? doc.verbs : {};
+ if (!fields.length) return `<p class="small">${why(Object.assign(new ReadError('invalid-response', ''), { module: true }))}</p>`;
+ const by = f => (Array.isArray(f.writes) ? f.writes : []).map(w => `${txt(WRITER[verbs[w.verb]] ?? 'unknown')} <span class="source"><code>${txt(w.verb)}</code></span>`).join('<br>');
+ const rows = fields.map(f => `<tr><th scope="row">${txt(f.label)}${f.note ? `<span class="source">${txt(f.note)}</span>` : ''}</th><td>${by(f)}</td></tr>`).join('');
+ return `<div class="table-wrap" tabindex="0" role="region" aria-label="Who may write each field"><table class="s1-table s1-writers"><thead><tr><th scope="col">Field</th><th scope="col">Writer and verb</th></tr></thead><tbody>${rows}</tbody></table></div>`;
+ }
async function taskView(get, ref) {
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
@@ -361,6 +375,7 @@
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${priority(t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
+ <section class="panel" aria-labelledby="b-w"><h2 id="b-w">Who may write</h2><p class="small muted">The writer each verb checks, from the slice 1 field table. Whether a write also needs a resolved decision comes from the role's authority, on <a href="#/business">Business</a>. Edits made in Vikunja aren't checked against this table.</p>${await writers(get)}</section>
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
}
diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs
index 01224d15..aa09515e 100644
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -23,6 +23,7 @@ import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } fr
import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs';
+import { loadFields, FieldTableError } from '../../tasks/src/fields.mjs';
export class ReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
@@ -33,6 +34,15 @@ export class ReadError extends Error {
const STATUS = { 'not-configured': 503, 'no-bus-host': 503, 'queue-refused': 503, 'read-failed': 503, 'not-found': 404, 'invalid-request': 400 };
export const readStatus = code => STATUS[code] ?? 502;
+// Row 61 (#1550): the task field table, packages/tasks/fields.json, read and checked on each
+// request. It is repository data, so it needs no business configured. A table that fails its
+// check is refused, never shown in part. Its messages name the file, never a path.
+export function fieldTable(file) {
+ try { return loadFields(file); } catch (err) {
+ throw new ReadError('read-failed', `the task field table did not load: ${err instanceof FieldTableError ? err.message : 'unreadable'}`);
+ }
+}
+
const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// V8's JSON.parse messages quote the input they failed on ("Unexpected
// token 'x', "…" is not valid JSON"). A business file is authority and
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 6209f9b9..f398eca1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
-import { ReadError, readStatus } from './reads.mjs';
+import { ReadError, readStatus, fieldTable } from './reads.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -129,8 +129,9 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
return { host, header, user: allowUser };
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
-// takes one line per send attempt that got past sign-in; never the body.
-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line) } = {}) {
+// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
+// tests; the field table is otherwise the committed packages/tasks/fields.json.
+export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
@@ -201,6 +202,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return busRead(res, bus, verb, search);
}
+ if (path === '/api/task-fields') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
+ return consoleRead(res, () => fieldTable(fieldsFile));
+ }
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index 9369de62..5e28c9d6 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -4,7 +4,7 @@
// CLI or reaches a real broker; the seeding writes go straight to the broker.
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
+import { copyFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
@@ -14,6 +14,7 @@ import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
+import { FIELDS_FILE } from '../../tasks/src/fields.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
const businesses = {
@@ -73,7 +74,10 @@ function seed(root) {
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
const s = seed(root), f = await fixture(), b = await browser();
- const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
+ // Row 61 (#1550): a copy of the field table, so the test can break it and read it again.
+ const fieldsFile = join(root, 'fields.json');
+ copyFileSync(FIELDS_FILE, fieldsFile);
+ const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
@@ -201,7 +205,23 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
assert.match(t7, /Snapshots 2/);
+ // Row 61 (#1550): who may write each field, by verb, from the field table.
+ assert.match(t7, /Who may writeThe writer each verb checks, from the slice 1 field table\. Whether a write also needs a resolved decision comes from the role's authority, on Business\./);
+ assert.match(t7, /Titlepm task\.createany role task\.scope\.change/);
+ assert.match(t7, /Prioritypm task\.createpm task\.priority\.change/);
+ assert.match(t7, /Percent donethe assigned role task\.update\.assigned/);
+ assert.match(t7, /DoneCites a review verdict, which is recorded and not checked\.pm task\.close/);
+ assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers tbody tr").length'), 11);
await inertCheck('task'); await noOverflow('task');
+ // A table that fails its check: the section says why, and the rest of the task page stays.
+ writeFileSync(fieldsFile, '{"source":');
+ await go('#/tasks/vikunja%3A32%2F8', '#8');
+ assert.match(await text('#bus-view'), /Who may writeThe writer.*The read did not finish\. \(read-failed\)the task field table did not load: fields\.json: not JSON/s);
+ assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers").length'), 0);
+ assert.match(await text('#bus-view'), /Not created by the stack; first read/);
+ copyFileSync(FIELDS_FILE, fieldsFile);
+ await go('#/tasks/vikunja%3A32%2F10', '#10');
+ assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers tbody tr").length'), 11);
await go('#/tasks/vikunja%3A32%2F8', '#8');
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
@@ -295,6 +315,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
const requests = await b.evaluate('window.requests');
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
+ assert.ok(requests.some(([, u]) => u === '/api/task-fields'));
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
// A Console with no bus configured says so, and its board still works.
diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs
index f5216ccf..13de88b2 100644
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -4,12 +4,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
-import { rmSync, writeFileSync } from 'node:fs';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
+import { FIELDS_FILE } from '../../tasks/src/fields.mjs';
async function serve(t, reads) {
const server = await startServer({ port: 0, reads });
@@ -196,3 +198,38 @@ test('settings: release, board, loopback address, notifier binding name only', a
assert.equal(r.status, 200);
assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
});
+
+// Row 61 (#1550): the field table is repository data, so it needs no business configured. A
+// table that fails its check is refused whole, with the loader's words and no path, and a fixed
+// file is read again on the next request.
+test('task fields: the committed table with no reads configured, GET only; a bad table refuses with no path', async t => {
+ const table = JSON.parse(readFileSync(FIELDS_FILE, 'utf8'));
+ let base = await serve(t, null);
+ let r = await get(base, '/api/task-fields');
+ assert.equal(r.status, 200, r.text);
+ assert.deepEqual(JSON.parse(r.text), table);
+ for (const method of ['POST', 'PUT', 'DELETE', 'HEAD']) assert.equal((await get(base, '/api/task-fields', { method })).status, 405, method);
+ assert.equal((await get(base, '/api/task-fields', { headers: { origin: 'https://evil.example' } })).status, 403);
+ const dir = mkdtempSync(join(tmpdir(), 'webui-fields-')), file = join(dir, 'fields.json');
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const server = await startServer({ port: 0, fieldsFile: file });
+ t.after(() => server.close());
+ base = `http://127.0.0.1:${server.address().port}`;
+ const bad = [
+ [null, 'fields.json: ENOENT'],
+ ['{"source":', 'fields.json: not JSON'],
+ [JSON.stringify({ ...table, verbs: { ...table.verbs, 'task.close': 'reviewer' } }), 'verbs.task.close: writer must be pm, assigned, any'],
+ [JSON.stringify({ ...table, fields: table.fields.filter(f => !['title', 'description'].includes(f.field)) }), 'verbs.task.scope.change: writes no field'],
+ ];
+ for (const [content, said] of bad) {
+ if (content !== null) writeFileSync(file, content);
+ r = await get(base, '/api/task-fields');
+ assert.equal(r.status, 503, said);
+ assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: `the task field table did not load: ${said}` });
+ assert.equal(r.text.includes(dir), false);
+ }
+ writeFileSync(file, JSON.stringify(table));
+ r = await get(base, '/api/task-fields');
+ assert.equal(r.status, 200);
+ assert.deepEqual(JSON.parse(r.text), table);
+});
@@ -0,0 +1,12 @@
975f3ba940f78d1dec40fc67799f1ba6c402583794eaa79b4926bf3bd51043ce docs/plans/2026-10-04_slice-1.md
152a16a2be9da101bc28b22592bb738c7f8c68894c318fdf65a2e0137502a0cd packages/tasks/README.md
2018701876f26c0566307335eae992efb3551b75af200b7c249b615757bf311a packages/tasks/fields.json
670bb2ff607719f93d81e2736c6bf8d306e82df9e450dbed98121addffabf668 packages/tasks/src/fields.mjs
f41a647f9c5bb65e11128c05554f65d20939f73adb8e75838c37431891fc9c99 packages/tasks/src/index.mjs
9dd930c36b831912b7204b108289dc78c7fe7c6e86416863f5792c2e9c76cc69 packages/tasks/tests/fields.test.mjs
dfc66d1314633a7311c517db677ac92e4d7b8057255a532a1317e76c4cf8b068 packages/webui/README.md
c03b6aec96a1c9e246a2302f5ddac4b6eb6e0734d892af038156e557a9ab089e packages/webui/src/public/bus.js
b3d62c13032de17f1e401b5cc3ccffaa76e6b2564b16101dba664c89da0b3188 packages/webui/src/reads.mjs
e4437c5b50ce54c04762a4c3b498a2efff96e552f7d78fdb9b402a22192a7edd packages/webui/src/serve.mjs
8dec3d9ad04d1917983c92b5c9b7055b39dbd5550836a0489195e370659b843d packages/webui/tests/bus-browser.test.mjs
ea5ee4ae79981ee1bf2808369dc64afa0fb4f5471e82f9f570e95cc435969ea7 packages/webui/tests/reads.test.mjs
@@ -0,0 +1,11 @@
tasks rc=0 18:39:31-18:39:35 ℹ pass 74 ℹ fail 0
webui rc=0 18:39:35-18:40:33 ℹ pass 82 ℹ fail 0
test-auth rc=0 18:40:33-18:40:34
test-conductor rc=0 18:40:34-18:40:41
test-config rc=0 18:40:41-18:40:43
test-discord rc=0 18:40:43-18:40:51
test-extension-package rc=0 18:40:51-18:40:53
test-foundation rc=0 18:40:53-18:41:44
test-queue rc=0 18:41:44-18:42:31 ℹ pass 150 ℹ fail 0
test-release rc=0 18:42:31-18:42:53
test-task rc=0 18:42:53-18:42:55
@@ -0,0 +1,3 @@
section-throws killed: ✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes;
section-writer-swap killed: ✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes;
section-no-note killed: ✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes;
@@ -0,0 +1,4 @@
required-key-optional killed: ✖ the schema refuses any other shape;
label-unbounded killed: ✖ the schema refuses any other shape;
verbs-subset killed: ✖ the schema refuses any other shape;
source-any killed: ✖ the schema refuses any other shape;
@@ -0,0 +1,30 @@
unknown-key-allowed killed: ✖ the schema refuses any other shape;
required-key-optional killed: ✖ the schema refuses any other shape;
writer-any-string killed: ✖ the schema refuses any other shape;
field-twice killed: ✖ the schema refuses any other shape;
field-name-any killed: ✖ the schema refuses any other shape;
label-unbounded killed: ✖ the schema refuses any other shape;
note-any killed: ✖ the schema refuses any other shape;
verb-twice killed: ✖ the schema refuses any other shape;
call-any killed: ✖ the schema refuses any other shape;
call-twice killed: ✖ the schema refuses any other shape;
unused-verb killed: ✖ the schema refuses any other shape;
verbs-subset killed: ✖ the schema refuses any other shape;
source-any killed: ✖ the schema refuses any other shape;
no-freeze killed: ✖ the committed table loads frozen; its verbs are the broker task verbs;
not-json-code killed: ✖ the schema refuses any other shape;
scope-pm killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
priority-any killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
update-any killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
close-assigned killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
reassign-no-delete killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
create-no-due killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
comments-by-close killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;✖ the schema refuses any other shape;
done-to-bucket killed: ✖ tests/fields.test.mjs;
code-priority-open killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
code-scope-pm killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
code-assigned-open killed: ✖ each verb refuses, asks for a decision and writes as the table and the role files say;
route-any-method killed: ✖ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path;
route-ignores-file killed: ✖ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path;
read-partial killed: ✖ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path;
read-any-message killed: ✖ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path;
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Darkwing row 61 mutants: single-site edits to the candidate in WORKTREE.
# Each mutant backs up one file, edits one site with mut.mjs (exactly one
# match, else BAD-SITE), runs the named test files and restores the backup.
# Usage: mutants.sh WORKTREE [browser|light|NAME,NAME...] (TMPDIR must be
# set; outputs go there). `light` skips the browser test mutants, `browser`
# runs only them, and a list of names runs only those.
set -u
WT="$1" ONLY="${2:-}"
MUT="$(cd "$(dirname "$0")/../queue-59-review/r1" && pwd)/mut.mjs"
cd "$WT" || exit 1
F=packages/tasks/src/fields.mjs J=packages/tasks/fields.json V=packages/tasks/src/verbs.mjs
S=packages/webui/src/serve.mjs R=packages/webui/src/reads.mjs B=packages/webui/src/public/bus.js
TF=tests/fields.test.mjs
m() { # name file pkg tests from to
local name="$1" file="$2" pkg="$3" tests="$4" from="$5" to="$6"
case "$ONLY" in
light) [[ $tests == *browser* ]] && return ;;
browser) [[ $tests == *browser* ]] || return ;;
'') ;;
*) [[ ,$ONLY, == *,$name,* ]] || return ;;
esac
cp -- "$file" "$TMPDIR/mut.bak"
if ! node "$MUT" "$file" "$from" "$to"; then echo "$name BAD-SITE"; cp -- "$TMPDIR/mut.bak" "$file"; return; fi
if (cd "packages/$pkg" && node --test $tests > "$TMPDIR/$name.out" 2>&1); then
echo "$name SURVIVED"
else
echo "$name killed: $(grep -E '^✖ ' "$TMPDIR/$name.out" | grep -v 'failing tests' | sort -u | sed 's/ ([0-9.]*ms)$//' | tr '\n' ';')"
fi
cp -- "$TMPDIR/mut.bak" "$file"
}
# The schema (fields.mjs).
m unknown-key-allowed $F tasks $TF "if (!allowed.includes(k)) throw" "if (false) throw"
m required-key-optional $F tasks $TF "if (!Object.hasOwn(x, k)) throw" "if (false) throw"
m writer-any-string $F tasks $TF "if (!WRITERS.includes(w)) throw" "if (typeof w !== 'string') throw"
m field-twice $F tasks $TF "if (seen.has(f.field)) throw" "if (false) throw"
m field-name-any $F tasks $TF "!FIELD.test(f.field)" "!f.field"
m label-unbounded $F tasks $TF "!str(f.label, 80)" "!str(f.label, 1e9)"
m note-any $F tasks $TF "f.note !== undefined && !str(f.note, 256)" "false"
m verb-twice $F tasks $TF "if (mine.has(w.verb)) throw" "if (false) throw"
m call-any $F tasks $TF "!CALL.test(c)" "!c"
m call-twice $F tasks $TF "new Set(calls).size !== calls.length" "false"
m unused-verb $F tasks $TF "if (!used.has(v)) throw" "if (false) throw"
m verbs-subset $F tasks $TF "exact(table.verbs, 'verbs', verbs);" "exact(table.verbs, 'verbs', verbs, []);"
m source-any $F tasks $TF "!str(table.source, 256)" "!table.source"
m no-freeze $F tasks $TF " Object.freeze(x);" " void x;"
m not-json-code $F tasks $TF "'fields.json: not JSON'" "'fields.json: unreadable'"
# The table (fields.json): the walk must notice data that no longer matches the code.
m scope-pm $J tasks $TF '"task.scope.change": "any"' '"task.scope.change": "pm"'
m priority-any $J tasks $TF '"task.priority.change": "pm"' '"task.priority.change": "any"'
m update-any $J tasks $TF '"task.update.assigned": "assigned"' '"task.update.assigned": "any"'
m close-assigned $J tasks $TF '"task.close": "pm"' '"task.close": "assigned"'
m reassign-no-delete $J tasks $TF '"calls": ["POST /tasks/{t}/assignees", "DELETE /tasks/{t}/assignees/{u}"]' '"calls": ["POST /tasks/{t}/assignees"]'
m create-no-due $J tasks $TF $' "label": "Due date",\n "writes": [\n { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },\n' $' "label": "Due date",\n "writes": [\n'
m comments-by-close $J tasks $TF '"writes": [{ "verb": "task.update.assigned", "calls": ["POST /tasks/{t}/comments"] }]' '"writes": [{ "verb": "task.close", "calls": ["POST /tasks/{t}/comments"] }]'
m done-to-bucket $J tasks $TF '"writes": [{ "verb": "task.close", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]' '"writes": [{ "verb": "task.update.assigned", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]'
# The code (verbs.mjs): the walk must notice code that no longer matches the table.
m code-priority-open $V tasks $TF "'task.schedule', 'task.priority.change', 'task.close'" "'task.schedule', 'task.close'"
m code-scope-pm $V tasks $TF "'task.priority.change', 'task.close']);" "'task.priority.change', 'task.close', 'task.scope.change']);"
m code-assigned-open $V tasks $TF "if (!fields.assignees.includes(mine.botId)) fail('not-assigned');" "void 0;"
# The webui route and section.
m route-any-method $S webui tests/reads.test.mjs "if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return consoleRead(res, () => fieldTable(fieldsFile));" "return consoleRead(res, () => fieldTable(fieldsFile));"
m route-ignores-file $S webui tests/reads.test.mjs "() => fieldTable(fieldsFile)" "() => fieldTable()"
m read-partial $R webui tests/reads.test.mjs " try { return loadFields(file); } catch (err) {" " try { return loadFields(file); } catch (err) { return {};"
m read-any-message $R webui tests/reads.test.mjs "err instanceof FieldTableError ? err.message : 'unreadable'" "'unreadable'"
m section-throws $B webui tests/bus-browser.test.mjs "catch (err) { return \`<p class=\"small\">\${why(err)}</p>\`; }" "catch (err) { throw err; }"
m section-writer-swap $B webui tests/bus-browser.test.mjs "const WRITER = { pm: 'pm', assigned: 'the assigned role', any: 'any role' };" "const WRITER = { pm: 'pm', assigned: 'any role', any: 'the assigned role' };"
m section-no-note $B webui tests/bus-browser.test.mjs "\${f.note ? \`<span class=\"source\">\${txt(f.note)}</span>\` : ''}" "''"