Files
stack/packages/tasks/src/fields.mjs
T
jason.woltjeandClaude Opus 5.5 6c42344d9d 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]>
2026-10-12 14:02:56 -05:00

77 lines
4.2 KiB
JavaScript

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));
}