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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}/`;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user