From f39023818f827f4e4dc2565c40b413298f67d9ba Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 13:43:39 -0500 Subject: [PATCH] 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 --- agents/darkwing/work/queue-61/build.md | 161 ++++ agents/darkwing/work/queue-61/build.patch | 723 ++++++++++++++++++ .../work/queue-61/candidate-manifest.sha256 | 12 + .../darkwing/work/queue-61/gate-summary.txt | 11 + .../work/queue-61/mutants-browser.txt | 3 + .../work/queue-61/mutants-light-r2.txt | 4 + .../darkwing/work/queue-61/mutants-light.txt | 30 + agents/darkwing/work/queue-61/mutants.sh | 74 ++ 8 files changed, 1018 insertions(+) create mode 100644 agents/darkwing/work/queue-61/build.md create mode 100644 agents/darkwing/work/queue-61/build.patch create mode 100644 agents/darkwing/work/queue-61/candidate-manifest.sha256 create mode 100644 agents/darkwing/work/queue-61/gate-summary.txt create mode 100644 agents/darkwing/work/queue-61/mutants-browser.txt create mode 100644 agents/darkwing/work/queue-61/mutants-light-r2.txt create mode 100644 agents/darkwing/work/queue-61/mutants-light.txt create mode 100755 agents/darkwing/work/queue-61/mutants.sh diff --git a/agents/darkwing/work/queue-61/build.md b/agents/darkwing/work/queue-61/build.md new file mode 100644 index 000000000..56773e14d --- /dev/null +++ b/agents/darkwing/work/queue-61/build.md @@ -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/`) 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`). diff --git a/agents/darkwing/work/queue-61/build.patch b/agents/darkwing/work/queue-61/build.patch new file mode 100644 index 000000000..651945603 --- /dev/null +++ b/agents/darkwing/work/queue-61/build.patch @@ -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 ? `

No longer readable ${missing.length}

    ${missing.map(t => `
  • ${title(t)} ${badge(`missing: ${t.missing.reason}`, 'danger')} since ${clock(t.missing.at)}
  • `).join('')}
` : ''; + return `${head('Tasks', freshness(rows))}

Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}

${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 `

${why(err)}

`; } ++ const fields = Array.isArray(doc.fields) ? doc.fields : [], verbs = doc.verbs && typeof doc.verbs === 'object' ? doc.verbs : {}; ++ if (!fields.length) return `

${why(Object.assign(new ReadError('invalid-response', ''), { module: true }))}

`; ++ const by = f => (Array.isArray(f.writes) ? f.writes : []).map(w => `${txt(WRITER[verbs[w.verb]] ?? 'unknown')} ${txt(w.verb)}`).join('
'); ++ const rows = fields.map(f => `${txt(f.label)}${f.note ? `${txt(f.note)}` : ''}${by(f)}`).join(''); ++ return `
${rows}
FieldWriter and verb
`; ++ } + 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 @@ +

Task

Ref
${txt(t.ref)} ${gap('The Vikunja address')}
Assignees
${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}
Requirement
${txt(t.requirement || 'none')}
Due
${due(t.due)}
Priority
${priority(t.priority)}
Done
${esc(t.percent)}%
${ext}${conflicts}${decisions}
+ ${t.description ? `

Description

${txt(t.description)}

` : ''} +
Snapshots ${t.snapshots.length}
    ${t.snapshots.map(s => `
  1. ${clock(s.at)} ${esc(bucketText(s.fields))} ${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' : ''}
  2. `).join('')}
++

Who may write

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 Business. Edits made in Vikunja aren't checked against this table.

${await writers(get)}
+

Trail

${trailList(lines(tr, row.seq), 'all')}

Trail with filters

`; + } + +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 = ' \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); ++}); diff --git a/agents/darkwing/work/queue-61/candidate-manifest.sha256 b/agents/darkwing/work/queue-61/candidate-manifest.sha256 new file mode 100644 index 000000000..fdbb8eb75 --- /dev/null +++ b/agents/darkwing/work/queue-61/candidate-manifest.sha256 @@ -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 diff --git a/agents/darkwing/work/queue-61/gate-summary.txt b/agents/darkwing/work/queue-61/gate-summary.txt new file mode 100644 index 000000000..3fe99508d --- /dev/null +++ b/agents/darkwing/work/queue-61/gate-summary.txt @@ -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 diff --git a/agents/darkwing/work/queue-61/mutants-browser.txt b/agents/darkwing/work/queue-61/mutants-browser.txt new file mode 100644 index 000000000..0557da31d --- /dev/null +++ b/agents/darkwing/work/queue-61/mutants-browser.txt @@ -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; diff --git a/agents/darkwing/work/queue-61/mutants-light-r2.txt b/agents/darkwing/work/queue-61/mutants-light-r2.txt new file mode 100644 index 000000000..3c38d5125 --- /dev/null +++ b/agents/darkwing/work/queue-61/mutants-light-r2.txt @@ -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; diff --git a/agents/darkwing/work/queue-61/mutants-light.txt b/agents/darkwing/work/queue-61/mutants-light.txt new file mode 100644 index 000000000..43b679452 --- /dev/null +++ b/agents/darkwing/work/queue-61/mutants-light.txt @@ -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; diff --git a/agents/darkwing/work/queue-61/mutants.sh b/agents/darkwing/work/queue-61/mutants.sh new file mode 100755 index 000000000..557617c80 --- /dev/null +++ b/agents/darkwing/work/queue-61/mutants.sh @@ -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 \`

\${why(err)}

\`; }" "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 ? \`\${txt(f.note)}\` : ''}" "''"