tasks, webui: the task field-writer table as data (#1550)

Row 61. packages/tasks/fields.json holds the slice 1 field table in two
layers (decision 113): each broker task verb's writer check, and each
field's writing verbs and Vikunja calls. packages/tasks/src/fields.mjs
checks the shape as code and loads it frozen. The tasks test reads the
writer layer from the table and authority from roles/*.json and drives
every task verb against the fake Vikunja. The Console serves the table
at GET /api/task-fields, and the task page shows a "Who may write"
section. The slice 1 doc's field table points to the file.

Darkwing's round 1, candidate manifest 33b96986, base 4f8ac1e7. Approved
by Dewey (27776) and Filbert (27785). Sage's gate green 18:57:31Z to
19:01:58Z.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 14:02:56 -05:00
co-authored by Claude Opus 5.5
parent 70ed77da34
commit 6c42344d9d
12 changed files with 519 additions and 12 deletions
+15 -6
View File
@@ -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 enforces on 2026-10-11 (lead decision 90). `message.send` is a bus
message and never writes a tracker comment. 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 | | 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}` | | 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, priority | pm | `task.schedule`, `task.priority.change` | `PATCH /tasks/{t}` | | due date | pm | `task.create`, `task.schedule` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
| percent done | the assigned role | `task.update.assigned` | `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}` |
| state (bucket) other than `done` | the assigned role | `task.update.assigned` | `PUT /projects/{p}/views/{k}/buckets/{b}/tasks` | | percent done | the assigned role; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `PATCH /tasks/{t}` |
| `done` | pm, after a review verdict | `task.close` | the same move, into the `done` bucket | | 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}` | | 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}` | | 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}` | | 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`, A `PATCH` body carries only `title`, `description`, `due_date`,
`priority` and `percent_done`, and only the keys the action owns. Vikunja `priority` and `percent_done`, and only the keys the action owns. Vikunja
+10
View File
@@ -119,6 +119,16 @@ All eight go through the same steps:
that change something are sent. that change something are sent.
7. A final read, then one self snapshot and its event. 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 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, 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 or the re-read fails too, the verb refuses with `write-uncertain`. A
+93
View File
@@ -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"] }]
}
]
}
+76
View File
@@ -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));
}
+1
View File
@@ -4,3 +4,4 @@ export { tick, reconcile, WINDOW_MS } from './sync.mjs';
export { verbs } from './verbs.mjs'; export { verbs } from './verbs.mjs';
export { client, apiRoot, outcome, refusal } from './vikunja.mjs'; export { client, apiRoot, outcome, refusal } from './vikunja.mjs';
export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs'; export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs';
export { loadFields, checkFields, FieldTableError, FIELDS_FILE, WRITERS } from './fields.mjs';
+218
View File
@@ -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);
});
+12
View File
@@ -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 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. 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. 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 - The Tasks table has Requirement and Priority columns, from the `tasks` row's
`requirement` and `priority` (row 58, #1547). Either one shows "none" when `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 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 answers 200 even without a system config, so appearance always works; its
notifier part then says `not-configured`. 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 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 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 the files under `src/public/assets/fonts/`. `live.css` contains the live-page
+15
View File
@@ -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>` : ''; 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}`; 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) { async function taskView(get, ref) {
const row = (await get('tasks')).find(r => r.task_ref === ref); const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`); 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> <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>` : ''} ${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> <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>`; <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>`;
} }
+10
View File
@@ -23,6 +23,7 @@ import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } fr
import { ACTIONS } from '../../business/src/vocabulary.mjs'; import { ACTIONS } from '../../business/src/vocabulary.mjs';
import { readHostState } from '../../cli/src/host.mjs'; import { readHostState } from '../../cli/src/host.mjs';
import { readNotifyConfig } from '../../cli/src/cli.mjs'; import { readNotifyConfig } from '../../cli/src/cli.mjs';
import { loadFields, FieldTableError } from '../../tasks/src/fields.mjs';
export class ReadError extends Error { export class ReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; } 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 }; 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; 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, '\\$&'); const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// V8's JSON.parse messages quote the input they failed on ("Unexpected // V8's JSON.parse messages quote the input they failed on ("Unexpected
// token 'x', "…" is not valid JSON"). A business file is authority and // token 'x', "…" is not valid JSON"). A business file is authority and
+8 -3
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs'; 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 const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) { export function isLoopback(host) {
@@ -129,8 +129,9 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
return { host, header, user: allowUser }; return { host, header, user: allowUser };
} }
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log` // `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. // takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
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) } = {}) { // 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 (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1'; if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board); 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' }); if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return busRead(res, bus, verb, search); 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 (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' }); 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}/`; const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
+23 -2
View File
@@ -4,7 +4,7 @@
// CLI or reaches a real broker; the seeding writes go straight to the broker. // CLI or reaches a real broker; the seeding writes go straight to the broker.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs'; import { Store } from '../../bus/src/store.mjs';
@@ -14,6 +14,7 @@ import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { fixture, close } from './fixture.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 HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
const businesses = { 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 () => { 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 root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
const s = seed(root), f = await fixture(), b = await 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; const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true }); 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)})})()`); 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, /Changed in Vikunja: bucket, and 2 new comments/);
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/); assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
assert.match(t7, /Snapshots 2/); 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'); 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'); 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'), /Not created by the stack; first read/);
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/); 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'); const requests = await b.evaluate('window.requests');
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []); assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject='))); 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']); assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
// A Console with no bus configured says so, and its board still works. // A Console with no bus configured says so, and its board still works.
+38 -1
View File
@@ -4,12 +4,14 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; 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 { join } from 'node:path';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs'; import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.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) { async function serve(t, reads) {
const server = await startServer({ port: 0, 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.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' }); 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);
});