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:
@@ -267,6 +267,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
since the list has no trail. The task page reads the trail and tells a task
|
||||
the stack never created from one changed in Vikunja after the stack wrote it.
|
||||
A stale poll read is shown in the snapshots and never as current.
|
||||
- The task page's "Who may write" section lists each field with the verbs
|
||||
that write it and the writer each verb checks: pm, the assigned role or any
|
||||
role (row 61, lead decision 113). Whether a write also needs a resolved
|
||||
decision is the role's authority, on the Business page. If the table read
|
||||
fails, the section says why and the rest of the page still shows.
|
||||
- The Tasks table has Requirement and Priority columns, from the `tasks` row's
|
||||
`requirement` and `priority` (row 58, #1547). Either one shows "none" when
|
||||
the row has none. A tombstone's page shows the priority of the last snapshot
|
||||
@@ -434,6 +439,13 @@ for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
|
||||
answers 200 even without a system config, so appearance always works; its
|
||||
notifier part then says `not-configured`.
|
||||
|
||||
GET `/api/task-fields` is row 61's read (#1550): the task field table,
|
||||
`packages/tasks/fields.json`, loaded and checked by `loadFields()` on each
|
||||
request. It is repository data, so it answers without `--business` or a
|
||||
system config. The answer is the table itself, with no `at`. A table that
|
||||
fails its check is refused whole with 503 `read-failed` and the loader's
|
||||
message, which names the file and never a path. Any other method is 405.
|
||||
|
||||
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
|
||||
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
|
||||
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
|
||||
|
||||
@@ -349,6 +349,20 @@
|
||||
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
|
||||
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`;
|
||||
}
|
||||
// Row 61 (#1550, lead decision 113): who may write each field, from the field table
|
||||
// (packages/tasks/fields.json, GET /api/task-fields). The table gives the writer each verb
|
||||
// checks; whether a write also needs a decision is each role's authority, on Business. A
|
||||
// failed read leaves the rest of the task page as it is and says why here.
|
||||
const WRITER = { pm: 'pm', assigned: 'the assigned role', any: 'any role' };
|
||||
async function writers(get) {
|
||||
let doc;
|
||||
try { doc = await get.api('task-fields'); } catch (err) { return `<p class="small">${why(err)}</p>`; }
|
||||
const fields = Array.isArray(doc.fields) ? doc.fields : [], verbs = doc.verbs && typeof doc.verbs === 'object' ? doc.verbs : {};
|
||||
if (!fields.length) return `<p class="small">${why(Object.assign(new ReadError('invalid-response', ''), { module: true }))}</p>`;
|
||||
const by = f => (Array.isArray(f.writes) ? f.writes : []).map(w => `${txt(WRITER[verbs[w.verb]] ?? 'unknown')} <span class="source"><code>${txt(w.verb)}</code></span>`).join('<br>');
|
||||
const rows = fields.map(f => `<tr><th scope="row">${txt(f.label)}${f.note ? `<span class="source">${txt(f.note)}</span>` : ''}</th><td>${by(f)}</td></tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Who may write each field"><table class="s1-table s1-writers"><thead><tr><th scope="col">Field</th><th scope="col">Writer and verb</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
async function taskView(get, ref) {
|
||||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||||
@@ -361,6 +375,7 @@
|
||||
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${priority(t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
|
||||
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
|
||||
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
|
||||
<section class="panel" aria-labelledby="b-w"><h2 id="b-w">Who may write</h2><p class="small muted">The writer each verb checks, from the slice 1 field table. Whether a write also needs a resolved decision comes from the role's authority, on <a href="#/business">Business</a>. Edits made in Vikunja aren't checked against this table.</p>${await writers(get)}</section>
|
||||
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { BusinessError, loadBusiness, resolveInstance, classify, systemVars } fr
|
||||
import { ACTIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { readNotifyConfig } from '../../cli/src/cli.mjs';
|
||||
import { loadFields, FieldTableError } from '../../tasks/src/fields.mjs';
|
||||
|
||||
export class ReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
@@ -33,6 +34,15 @@ export class ReadError extends Error {
|
||||
const STATUS = { 'not-configured': 503, 'no-bus-host': 503, 'queue-refused': 503, 'read-failed': 503, 'not-found': 404, 'invalid-request': 400 };
|
||||
export const readStatus = code => STATUS[code] ?? 502;
|
||||
|
||||
// Row 61 (#1550): the task field table, packages/tasks/fields.json, read and checked on each
|
||||
// request. It is repository data, so it needs no business configured. A table that fails its
|
||||
// check is refused, never shown in part. Its messages name the file, never a path.
|
||||
export function fieldTable(file) {
|
||||
try { return loadFields(file); } catch (err) {
|
||||
throw new ReadError('read-failed', `the task field table did not load: ${err instanceof FieldTableError ? err.message : 'unreadable'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// V8's JSON.parse messages quote the input they failed on ("Unexpected
|
||||
// token 'x', "…" is not valid JSON"). A business file is authority and
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||||
import { ReadError, readStatus } from './reads.mjs';
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -129,8 +129,9 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
|
||||
return { host, header, user: allowUser };
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line) } = {}) {
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -201,6 +202,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return busRead(res, bus, verb, search);
|
||||
}
|
||||
if (path === '/api/task-fields') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return consoleRead(res, () => fieldTable(fieldsFile));
|
||||
}
|
||||
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// CLI or reaches a real broker; the seeding writes go straight to the broker.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
@@ -14,6 +14,7 @@ import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { FIELDS_FILE } from '../../tasks/src/fields.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
||||
const businesses = {
|
||||
@@ -73,7 +74,10 @@ function seed(root) {
|
||||
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
|
||||
const s = seed(root), f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
// Row 61 (#1550): a copy of the field table, so the test can break it and read it again.
|
||||
const fieldsFile = join(root, 'fields.json');
|
||||
copyFileSync(FIELDS_FILE, fieldsFile);
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
@@ -201,7 +205,23 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||||
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
|
||||
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
|
||||
assert.match(t7, /Snapshots 2/);
|
||||
// Row 61 (#1550): who may write each field, by verb, from the field table.
|
||||
assert.match(t7, /Who may writeThe writer each verb checks, from the slice 1 field table\. Whether a write also needs a resolved decision comes from the role's authority, on Business\./);
|
||||
assert.match(t7, /Titlepm task\.createany role task\.scope\.change/);
|
||||
assert.match(t7, /Prioritypm task\.createpm task\.priority\.change/);
|
||||
assert.match(t7, /Percent donethe assigned role task\.update\.assigned/);
|
||||
assert.match(t7, /DoneCites a review verdict, which is recorded and not checked\.pm task\.close/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers tbody tr").length'), 11);
|
||||
await inertCheck('task'); await noOverflow('task');
|
||||
// A table that fails its check: the section says why, and the rest of the task page stays.
|
||||
writeFileSync(fieldsFile, '{"source":');
|
||||
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
||||
assert.match(await text('#bus-view'), /Who may writeThe writer.*The read did not finish\. \(read-failed\)the task field table did not load: fields\.json: not JSON/s);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers").length'), 0);
|
||||
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
||||
copyFileSync(FIELDS_FILE, fieldsFile);
|
||||
await go('#/tasks/vikunja%3A32%2F10', '#10');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view table.s1-writers tbody tr").length'), 11);
|
||||
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
||||
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
||||
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
|
||||
@@ -295,6 +315,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||||
const requests = await b.evaluate('window.requests');
|
||||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||||
assert.ok(requests.some(([, u]) => u === '/api/task-fields'));
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
|
||||
// A Console with no bus configured says so, and its board still works.
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
|
||||
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
|
||||
import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
|
||||
import { FIELDS_FILE } from '../../tasks/src/fields.mjs';
|
||||
|
||||
async function serve(t, reads) {
|
||||
const server = await startServer({ port: 0, reads });
|
||||
@@ -196,3 +198,38 @@ test('settings: release, board, loopback address, notifier binding name only', a
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
|
||||
});
|
||||
|
||||
// Row 61 (#1550): the field table is repository data, so it needs no business configured. A
|
||||
// table that fails its check is refused whole, with the loader's words and no path, and a fixed
|
||||
// file is read again on the next request.
|
||||
test('task fields: the committed table with no reads configured, GET only; a bad table refuses with no path', async t => {
|
||||
const table = JSON.parse(readFileSync(FIELDS_FILE, 'utf8'));
|
||||
let base = await serve(t, null);
|
||||
let r = await get(base, '/api/task-fields');
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(JSON.parse(r.text), table);
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD']) assert.equal((await get(base, '/api/task-fields', { method })).status, 405, method);
|
||||
assert.equal((await get(base, '/api/task-fields', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-fields-')), file = join(dir, 'fields.json');
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const server = await startServer({ port: 0, fieldsFile: file });
|
||||
t.after(() => server.close());
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
const bad = [
|
||||
[null, 'fields.json: ENOENT'],
|
||||
['{"source":', 'fields.json: not JSON'],
|
||||
[JSON.stringify({ ...table, verbs: { ...table.verbs, 'task.close': 'reviewer' } }), 'verbs.task.close: writer must be pm, assigned, any'],
|
||||
[JSON.stringify({ ...table, fields: table.fields.filter(f => !['title', 'description'].includes(f.field)) }), 'verbs.task.scope.change: writes no field'],
|
||||
];
|
||||
for (const [content, said] of bad) {
|
||||
if (content !== null) writeFileSync(file, content);
|
||||
r = await get(base, '/api/task-fields');
|
||||
assert.equal(r.status, 503, said);
|
||||
assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: `the task field table did not load: ${said}` });
|
||||
assert.equal(r.text.includes(dir), false);
|
||||
}
|
||||
writeFileSync(file, JSON.stringify(table));
|
||||
r = await get(base, '/api/task-fields');
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(JSON.parse(r.text), table);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user