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