review(darkwing): row 58 round 1 packet, task priority and requirement on the bus read (#1547)

Candidate build.patch on ae428a54, manifest e63e4c4f. Gate on f2f2861b: bus 83,
tasks 71, webui 40, every scripts/test-*.sh exit 0. Mutants 16 of 17 killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 17:23:08 -05:00
co-authored by Claude Opus 5.5
parent e8ad8b4121
commit 7f92a1039b
23 changed files with 1434 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
# Row 58 (#1547): bus read, task priority and requirement
Brief: `docs/plans/2026-10-10_design-implementation.md`, section "Bus read:
task priority and requirement" (blob `a360554c`). Owner Darkwing; reviewers
Dewey and Filbert.
The candidate is `build.patch` against the base in `base.txt`, 8 files in
`files.txt`. `candidate-manifest.sha256` holds the sha256 of each file after
the patch.
I built it on `72b16a5b`. Row 57 (#1546) then landed as `f78ba57d` and
changed four of these files: `broker.mjs`, both READMEs and the webui
`bus.test.mjs`. `git apply --3way` put the patch on `ae428a54` with no
conflict. Row 57 adds the decisions reads and this row changes the
`tasks` rows, so the hunks sit in different places; I read the merged
`broker.mjs` and both changes are intact. `build.patch`, the manifest and
the mutants are on `ae428a54`. The gate ran on `f2f2861b`, after row 62
landed; no candidate file changed between the two.
## The problem
The brief says priority isn't in `task_current.fields`. It is:
`taskFields()` in `packages/tasks/src/digest.mjs` puts `priority` in every
live snapshot, `checkTask()` refuses a task without an integer priority, and
the Tasks table already showed it from the fields. What the read model
lacked was a named priority field, and the requirement, which sat only in
the `task.created` body (`packages/tasks/src/verbs.mjs`). The list never
reads that body, since the list has no trail. So for priority this row
lifts an existing value to a top-level key; the requirement is new data in
the list. The brief asks for both fields in the task read model, a
projection rebuild test, and the two columns in the Tasks view.
## What changed
### The bus `tasks` rows
`Broker.#taskRows()` in `packages/bus/src/broker.mjs` builds the rows the
`tasks` verb returns. Each is `task_current`'s row as before, with two keys
added:
- `priority` is the current row's `fields.priority`. It is null for a
tombstone, whose fields are only `gone` (and `project` on a move), and for
a read with no priority. Vikunja's 0 stays 0.
- `requirement` is the requirement in the first `task.created` for the same
Vikunja task id in the business. A move to another project changes the
project in the ref but not the task id, so the moved task keeps the
requirement it was created with. It is null for a task the stack didn't
create.
Both are computed from append-only rows at each read; nothing new is stored.
I chose that over a schema change. `store.mjs` checks the DDL against
`SCHEMA_SHA256` and an open bus against the reference digest, so a new
column or a changed `task_current` view needs a schema version and a
migration of every existing `bus.sqlite`. A read-time projection needs
neither, and a reopened bus gives the same rows by construction.
`taskView()`, the trusted S3 read, is unchanged and still returns
`task_current`'s rows only. Its comment now names `#taskRows` as the verb's
read.
The requirement read is one query per `tasks` call over the business's
`task.created` events. It isn't indexed by kind; a business with many
thousands of created tasks would make each Tasks refresh scan them. That is
fine at slice 1 sizes, and a later row can add an index if a real business
needs one.
### The Tasks view
`packages/webui/src/public/bus.js`:
- `task()` takes `requirement` from the bus row first, then from the trail's
`task.created`, so the list, which reads no trail, now has it. Priority is
the row's, then the last snapshot that read the task (a tombstone's page),
then null. Before, a missing priority became 0 and showed "unset". A
poller read always has one, so "none" shows only for a row written without
it, like the browser test's #9.
- The Tasks table gains a Requirement column after Task. Requirement and
Priority show a muted "none" when the row has none, in the list and on the
task page.
- `bus.css`: the tasks table's minimum width goes from 900px to 1020px for
the extra column. The browser test's overflow checks still pass.
For a live row the bus `priority` and the fields' `priority` are the same
value, so preferring the row's changes nothing visible today. I kept it so
the view reads the field the brief puts in the read model; the mutant that
drops it survives for that reason (below).
### Docs
`packages/bus/README.md` describes the two keys under "Shared reads and
adapter boundary". `packages/webui/README.md` adds one bullet on the
columns to the S5 section.
## Scope
The brief owns `packages/bus/`, `packages/tasks/`, the webui Tasks endpoint
and tests. The webui `bus.js`, `bus.css` and README edits are the Tasks view
the brief asks for. Nothing in `packages/tasks/` changed: the poller already
writes `priority` in each snapshot's fields, and `task.create` writes
`requirement` in `task.created`. Field writers are row 61.
## Tests
New `packages/bus/tests/task-read.test.mjs`, 3 tests. The seed has six demo
tasks: created by the stack, changed outside it since, never seen by the
stack, created then gone, created then moved to another project, and a
second `task.created` for the moved task's new ref. A second business reuses
one task id with its own requirement.
1. "tasks rows carry priority and requirement for every session that reads
tasks": the human, a reader and a role session get the same rows. A later
poll's priority wins over the stack's write, 0 stays 0, tombstones have
none, the moved task keeps its first requirement, and the other business
sees only its own. Each row minus the two keys equals `taskView(...,
'current')`'s row, and `taskView` has neither key.
2. "the read model rebuilds from the ledger": the rows after a close and
reopen equal the rows before. Each row's priority equals its current
snapshot's in `trail`, and its requirement equals the first `task.created`
for its task id in `events`. A read writes nothing.
3. "an empty business and one with snapshots but no task.created read
cleanly".
All three fail on the base broker (`out/task-read-on-base.log`).
`packages/webui/tests/bus.test.mjs`: the endpoint test asserts the row's
`priority` and `requirement` reach `/api/bus/tasks` as null, not missing.
`packages/webui/tests/bus-browser.test.mjs`:
- The done task (#9) is now written with no priority.
- The Tasks list check reads each table's header and each row's Requirement
and Priority cells: #8 `none`/`high`, #7 `REQ-SLICE-1`/`high`, #9
`none`/`none`. It replaces the `/high/` match.
- #7's page shows `Priorityhigh`. The moved task's page (#10, a tombstone)
shows `Priorityhigh` from its last snapshot.
- #9's page shows `Requirementnone` and `Prioritynone`. This visit comes
after the "never read" failure check, which needs #9's trail unread; an
earlier visit made that check fail.
## Mutants
`out/mutants.sh` edits one source file per mutant, runs the one test file
meant to fail it, and restores the file from the build worktree's index,
which holds the candidate. Output: `out/mutants.txt`, from the run on `ae428a54` at 22:22 UTC on
2026-10-11. 16 of 17 fail, as they did on `72b16a5b`; each webui kill
failed on the intended assertion.
| Mutant | Fails |
|---|---|
| `tasks-verb-unchanged`: the verb returns `task_current` as before | task-read 1, 2, 3 |
| `no-priority`: priority always null | task-read 1, 2 |
| `zero-priority-null`: `\|\| null`, so 0 becomes null | task-read 1, 2 |
| `tombstone-priority-zero`: `?? 0` | task-read 1, 2, 3 |
| `requirement-by-ref`: keyed by the whole ref | task-read 1, 2 |
| `requirement-last-wins`: the last `task.created` wins | task-read 1, 2 |
| `requirement-any-business`: events from every business | task-read 1 |
| `requirement-undefined`: no `?? null` | task-read 1, 2, 3 |
| `task-view-adds-keys`: `taskView` grows the keys too | task-read 1 |
| `endpoint-drops-keys`: the rows lose both keys | webui bus endpoint test |
| `list-requirement-from-trail`: requirement only from the trail | browser |
| `no-requirement-column` | browser |
| `priority-default-zero`: `?? 0` in the view | browser |
| `tombstone-no-fallback`: no snapshot fallback | browser |
| `detail-priority-raw`: the task page prints the raw value | browser |
| `list-priority-raw`: the list prints the raw value | browser |
| `ignore-row-priority`: the view reads only the fields' priority | survives |
`ignore-row-priority` is equivalent today, as said above.
## Gate
`out/gate/gate.sh` ran on 2026-10-11 from 21:57 to 22:02 UTC. Its worktree
was at `f2f2861b` with `build.patch` applied (`out/gate/apply.txt`) and
`node_modules` linked to the main checkout's. It ran each suite in turn and
wrote each log to `out/gate/`. The brief names bus, tasks and webui plus
every `scripts/test-*.sh`.
| Suite | Result |
|---|---|
| packages/bus | 83 pass, 0 fail |
| packages/tasks | 71 pass, 0 fail |
| packages/webui | 40 pass, 0 fail, the browser tests included |
| test-auth | 15 passed |
| test-conductor | 17 passed |
| test-config | 24 passed |
| test-discord | 66 passed |
| test-extension-package | 18 passed |
| test-foundation | 44 passed |
| test-queue | 27 passed |
| test-release | 14 passed |
| test-task | 26 passed |
`test-task` ran with `DOCKER_HOST` set to a missing socket and skipped its
live recall pair. `test-queue` skipped its canonical-root check, since the
gate worktree isn't the queue's root. Sage's gate rerun covers both.
After the gate a `gate_default` Docker network was left with no
containers, and I removed it.
Candidate manifest sha256:
`e63e4c4f756229a9e5bba276c908e272fae0a71ae4f0a1c47e7e47b630bc63e6`.
+1
View File
@@ -0,0 +1 @@
ae428a5409a9171e0879e3ed843319e68be20ff8
+367
View File
@@ -0,0 +1,367 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 5b57b7fc..7fa51f92 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -221,7 +221,11 @@ await read.decisions('roles'); await read.decisions('recent');
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
The tasks projection reports the schema’s `task_current` view, including tombstones; the caller
-must not pretend a missing/inaccessible task is open or completed. Trail joins
+must not pretend a missing/inaccessible task is open or completed. Each row adds two keys,
+computed at each read and never stored. `priority` is the current row's `fields.priority`, null
+for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
+first `task.created` for the same Vikunja task id in the business, so a task moved to another
+project keeps it; null for a task the stack didn't create. Trail joins
human request, messages/deliveries, decisions/resolutions, task snapshots and
session/claim evidence. Broker timestamps advance monotonically across all
its writes, including after reopen; per-table `seq` stays the primary table
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 84db8799..595ca232 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -303,6 +303,26 @@ export class Broker {
id,
);
}
+ // Row 58 (#1547): the 'tasks' rows, each with the task's priority and requirement. Priority
+ // is the current row's; a tombstone has none. The requirement is the first task.created for
+ // the same Vikunja task id, which a move to another project keeps; null for a task the stack
+ // didn't create. Both come from append-only rows at each read; nothing is stored.
+ #taskRows(business) {
+ const taskId = (ref) => ref.slice(ref.indexOf('/') + 1);
+ const requirement = new Map();
+ for (const e of this.#store.all(
+ "SELECT subject,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq",
+ business,
+ ))
+ if (!requirement.has(taskId(e.subject))) requirement.set(taskId(e.subject), e.requirement);
+ return this.#store
+ .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business)
+ .map((r) => {
+ const fields = JSON.parse(r.fields);
+ const priority = fields.priority ?? null;
+ return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
+ });
+ }
#checkAuthority(s, action, { decision = null, target = null } = {}) {
this.#agent(s);
if (
@@ -418,7 +438,8 @@ export class Broker {
s.run,
).lastInsertRowid;
}
- // Trusted S3 reads. Agents read the same rows through the 'tasks' view verb.
+ // Trusted S3 reads. Agents read the same rows through the 'tasks' view verb, which adds
+ // priority and requirement (#taskRows).
taskView(business, view, arg = null) {
this.#business(business);
const parse = (r) => ({ ...r, fields: JSON.parse(r.fields) });
@@ -830,10 +851,7 @@ export class Broker {
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
s.business,
);
- if (verb === 'tasks')
- return this.#store
- .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', s.business)
- .map((r) => ({ ...r, fields: JSON.parse(r.fields) }));
+ if (verb === 'tasks') return this.#taskRows(s.business);
return this.#trail(s, identifier(a.subject));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
diff --git a/packages/bus/tests/task-read.test.mjs b/packages/bus/tests/task-read.test.mjs
new file mode 100644
index 00000000..2f353d1b
--- /dev/null
+++ b/packages/bus/tests/task-read.test.mjs
@@ -0,0 +1,151 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker } from '../src/broker.mjs';
+// Row 58 (#1547): each 'tasks' row carries the task's priority and requirement, read from the
+// append-only task_snapshots and task.created rows, for every session that may read tasks.
+const roles = { pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } } };
+const businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
+ other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
+};
+const at = '2026-10-08T12:00:00.000Z';
+// task_current keeps a poll only when it was read after the last self snapshot, whose `at` is the
+// broker's clock, so a read after a write must be later than now.
+const later = new Date(Date.now() + 3600e3).toISOString();
+const digest = 'c'.repeat(64);
+const fields = (extra = {}) => ({ bucket: 11, done: false, priority: 0, ...extra });
+const poll = (task_ref, f, extra = {}) => ({ task_ref, updated: at, digest, fields: f, via: 'board', read_at: at, ...extra });
+const gone = (task_ref, f) => poll(task_ref, f, { via: 'task', read_at: later });
+const tasks = (b, cap) => b.request(cap, { verb: 'tasks', args: {} });
+const pick = (rows) => rows.map((r) => [r.task_ref, r.priority, r.requirement]);
+function open(root) {
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const agent = (business) => {
+ const cap = b.bindLaunch({ business, role: 'pm', run: `${business}-pm`, harness: 'pi' });
+ b.request(cap, { verb: 'role.claim' });
+ return cap;
+ };
+ return { store, b, human, reader: b.bindReader({ business: 'demo' }), agent };
+}
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-task-read-'));
+ const bus = { root, ...open(root) };
+ t.after(() => {
+ bus.store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ return bus;
+}
+// The pm creates a task the way packages/tasks does: a self snapshot and task.created.
+function create(b, cap, human, business, task_ref, requirement, f) {
+ const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } });
+ b.recordTask({
+ cap,
+ business,
+ snapshots: [{ task_ref, updated: at, digest, fields: f }],
+ events: [{ kind: 'task.created', subject: task_ref, body: { request, requirement } }],
+ });
+}
+// Six demo tasks: created by the stack (3/7), changed outside it since (3/8), never seen by the
+// stack (3/9), created then gone (3/10), created then moved to project 4 (3/11 -> 4/11), and a
+// second task.created for 4/11's id.
+function seed(bus) {
+ const { b, human, agent } = bus;
+ const pm = agent('demo');
+ create(b, pm, human, 'demo', 'vikunja:3/7', 'REQ-SLICE-1', fields({ priority: 3 }));
+ create(b, pm, human, 'demo', 'vikunja:3/8', 'REQ-SLICE-2', fields({ priority: 1 }));
+ create(b, pm, human, 'demo', 'vikunja:3/10', 'REQ-SLICE-3', fields({ priority: 4 }));
+ create(b, pm, human, 'demo', 'vikunja:3/11', 'REQ-MOVE-1', fields({ priority: 2 }));
+ b.recordTask({
+ business: 'demo',
+ snapshots: [
+ poll('vikunja:3/8', fields({ priority: 5 }), { read_at: later }),
+ poll('vikunja:3/9', fields()),
+ gone('vikunja:3/10', { gone: 'no-access' }),
+ gone('vikunja:3/11', { gone: 'moved', project: 4 }),
+ poll('vikunja:4/11', fields({ priority: 2, bucket: 21 }), { via: 'reconcile', read_at: later }),
+ ],
+ });
+ create(b, pm, human, 'demo', 'vikunja:4/11', 'REQ-MOVE-2', fields({ priority: 2, bucket: 21 }));
+ // The same task id in another business has its own requirement.
+ const { request } = b.request(b.bindHuman({ business: 'other', human: 'jason', via: 'cli', outsideAgent: true }), {
+ verb: 'message.send',
+ args: { to: 'pm', body: 'please' },
+ });
+ b.recordTask({
+ cap: agent('other'),
+ business: 'other',
+ snapshots: [{ task_ref: 'vikunja:3/7', updated: at, digest, fields: fields({ priority: 1 }) }],
+ events: [{ kind: 'task.created', subject: 'vikunja:3/7', body: { request, requirement: 'REQ-OTHER-1' } }],
+ });
+ return pm;
+}
+const EXPECTED = [
+ ['vikunja:3/10', null, 'REQ-SLICE-3'],
+ ['vikunja:3/11', null, 'REQ-MOVE-1'],
+ ['vikunja:3/7', 3, 'REQ-SLICE-1'],
+ ['vikunja:3/8', 5, 'REQ-SLICE-2'],
+ ['vikunja:3/9', 0, null],
+ ['vikunja:4/11', 2, 'REQ-MOVE-1'],
+];
+
+test('tasks rows carry priority and requirement for every session that reads tasks', (t) => {
+ const bus = setup(t);
+ const pm = seed(bus);
+ for (const cap of [bus.human, bus.reader, pm]) assert.deepEqual(pick(tasks(bus.b, cap)), EXPECTED);
+ // The priority is the current row's: a later poll wins over the stack's write (3/8), and Vikunja's
+ // 0 stays 0, not null (3/9). A tombstone has no priority (3/10, 3/11).
+ // The requirement follows the task id: 4/11 keeps the one 3/11 was created with, not the second
+ // task.created, and a task the stack never created has none (3/9).
+ const other = bus.b.bindReader({ business: 'other' });
+ assert.deepEqual(pick(tasks(bus.b, other)), [['vikunja:3/7', 1, 'REQ-OTHER-1']]);
+ // Nothing else in the row changes: it is task_current's row with two keys added.
+ const current = bus.b.taskView('demo', 'current');
+ assert.deepEqual(
+ tasks(bus.b, bus.reader).map(({ priority, requirement, ...r }) => ({ ...r })),
+ current.map((r) => ({ ...r })),
+ );
+ assert.equal('priority' in current[0] || 'requirement' in current[0], false);
+});
+
+test('the read model rebuilds from the ledger: a reopened bus and each trail give the same values', (t) => {
+ const bus = setup(t);
+ seed(bus);
+ const before = tasks(bus.b, bus.reader);
+ bus.store.close();
+ Object.assign(bus, open(bus.root));
+ const after = tasks(bus.b, bus.reader);
+ assert.deepEqual(after, before);
+ assert.deepEqual(pick(after), EXPECTED);
+ // Each value is what the append-only rows say: the priority of the current snapshot, and the
+ // requirement of the first task.created for the task's id, read through trail.
+ for (const row of after) {
+ const trail = bus.b.request(bus.reader, { verb: 'trail', args: { subject: row.task_ref } });
+ const snap = trail.find((r) => r.table === 'task_snapshots' && r.seq === row.seq);
+ assert.equal(snap.fields.priority ?? null, row.priority, row.task_ref);
+ const id = row.task_ref.split('/')[1];
+ const created = bus.store
+ .all("SELECT subject, body FROM events WHERE business='demo' AND kind='task.created' ORDER BY seq")
+ .find((e) => e.subject.split('/')[1] === id);
+ assert.equal(created ? JSON.parse(created.body).requirement : null, row.requirement, row.task_ref);
+ }
+ // A read writes nothing.
+ const count = () => bus.store.get('SELECT (SELECT count(*) FROM events) + (SELECT count(*) FROM task_snapshots) n').n;
+ const n = count();
+ tasks(bus.b, bus.human);
+ tasks(bus.b, bus.reader);
+ assert.equal(count(), n);
+});
+
+test('an empty business and one with snapshots but no task.created read cleanly', (t) => {
+ const bus = setup(t);
+ assert.deepEqual(tasks(bus.b, bus.reader), []);
+ bus.b.recordTask({ business: 'demo', snapshots: [poll('vikunja:5/1', { bucket: 0, done: false })] });
+ assert.deepEqual(pick(tasks(bus.b, bus.reader)), [['vikunja:5/1', null, null]]);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index e43118d5..88df712a 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -87,6 +87,10 @@ 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 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
+ that read the task.
- All bus- and agent-authored text is rendered inert: controls show as control
pictures or `[U+XXXX]`, and Markdown stays as source.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css
index 39cf2991..acf496a5 100644
--- a/packages/webui/src/public/bus.css
+++ b/packages/webui/src/public/bus.css
@@ -25,7 +25,7 @@ body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
/* Tables */
-table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
+table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:1020px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index affb9b7b..3be22af5 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -105,8 +105,10 @@
return {
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
- requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
- due: shape.due_date ?? null, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
+ // The bus 'tasks' row carries both (row 58). A tombstone's row has no priority, so the detail
+ // view falls back to the last snapshot that read the task.
+ requirement: row.requirement ?? created?.body?.requirement ?? null, request: created?.body?.request ?? null,
+ due: shape.due_date ?? null, priority: row.priority ?? shape.priority ?? null, percent: shape.percent_done ?? 0,
changedAt: row.at, seq: row.seq, external,
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
@@ -287,10 +289,11 @@
return out.join(' ');
}
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
+ const priority = p => p === null ? '<span class="muted">none</span>' : esc(PRIORITY[p] || p);
function taskRow(t) {
- return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
+ return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.requirement ? txt(t.requirement) : '<span class="muted">none</span>'}</td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${priority(t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
}
- const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
+ const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
function freshness(rows) {
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
@@ -318,7 +321,7 @@
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
return `${head(title(t), `${sub} ${markers(t)}`)}
- <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>${esc(PRIORITY[t.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>` : ''}
<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 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>`;
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index aebd3c30..9faa15eb 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -46,7 +46,7 @@ function seed(root) {
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
// A task the stack never wrote, a done task and one no longer readable.
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
- b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
+ b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100, priority: undefined } }] });
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
@@ -168,7 +168,14 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
assert.match(tasks, /#10 \(no title in the current read\)/);
- assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
+ assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/);
+ // Requirement and priority come from the bus row (row 58). The list reads no trail, so #7's
+ // requirement is the row's. #8 and #9 have no task.created, and #9 was written with no priority.
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-tasks")].map(t=>[[...t.tHead.rows[0].cells].map(c=>c.textContent),...[...t.tBodies[0].rows].map(r=>[r.cells[1].textContent,r.cells[4].textContent])])'), [
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'high']],
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['REQ-SLICE-1', 'high']],
+ ['Task Requirement Assignees Due Priority Done Changed Markers'.split(' '), ['none', 'none']],
+ ]);
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
@@ -178,6 +185,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
await go('#/tasks/vikunja%3A32%2F7', '#7');
const t7 = await text('#bus-view');
assert.match(t7, /RequirementREQ-SLICE-1/);
+ assert.match(t7, /Priorityhigh/);
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
@@ -194,6 +202,8 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
await go('#/tasks/vikunja%3A32%2F10', '#10');
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
+ // A tombstone's bus row has no priority; the detail shows the last snapshot that read the task.
+ assert.match(await text('#bus-view'), /Priorityhigh/);
// The trail with filters.
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
@@ -238,6 +248,11 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
await noOverflow('refused');
+ // #9 was written with no priority and has no task.created. This runs after the never-read
+ // check above, which needs #9's trail unread.
+ await go('#/tasks/vikunja%3A32%2F9', '#9');
+ assert.match(await text('#bus-view'), /Requirementnone/);
+ assert.match(await text('#bus-view'), /Prioritynone/);
// Back to the board: it still works.
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index 2d9ca6ba..cf477052 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -67,6 +67,9 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
assert.equal(tasks.body.rows.length, 1);
assert.equal(tasks.body.rows[0].source, 'poll');
assert.equal(tasks.body.rows[0].fields.title, HOSTILE);
+ // The bus row's priority and requirement (row 58) reach the page: this poll has no priority and
+ // no task.created, so both are null, not missing.
+ assert.deepEqual([tasks.body.rows[0].priority, tasks.body.rows[0].requirement], [null, null]);
const agents = await read('/api/bus/agents');
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
@@ -0,0 +1,8 @@
ef35b56f728caeb7c2f2094f654ec03cc68cab36ccaa942b7eaf23812cb14cd1 packages/bus/README.md
1dcb833f4555bc808a3b46d000c27376a387d4dd05825ff74b59551bd3a07d48 packages/bus/src/broker.mjs
a61476e7a7b14036f250e824e7ed545031d9a4cc58d91ec0c577f2b09ab4fc46 packages/bus/tests/task-read.test.mjs
034b6758820fd183bbb1c8182d207c60234842fb180efcc6b4d839068accf8b8 packages/webui/README.md
847e21519333c9f03dc4a726cf51c03552cb638c2de4e844431fb661740e534e packages/webui/src/public/bus.css
f5908e144a66839779cfbc97eaef98bd58f819bda32d0259ae9b358915fb8508 packages/webui/src/public/bus.js
b9d496c16162c72e370acc589c95ac3db8649ca4d00f2c270cfdad9f34a08656 packages/webui/tests/bus-browser.test.mjs
d2f1e1c6bd19e92c0c5ab24eaaed469eac5a5ae11db0dca7eb6c603f5a99c674 packages/webui/tests/bus.test.mjs
+8
View File
@@ -0,0 +1,8 @@
packages/bus/README.md
packages/bus/src/broker.mjs
packages/bus/tests/task-read.test.mjs
packages/webui/README.md
packages/webui/src/public/bus.css
packages/webui/src/public/bus.js
packages/webui/tests/bus-browser.test.mjs
packages/webui/tests/bus.test.mjs
@@ -0,0 +1,8 @@
packages/bus/README.md: OK
packages/bus/src/broker.mjs: OK
packages/bus/tests/task-read.test.mjs: OK
packages/webui/README.md: OK
packages/webui/src/public/bus.css: OK
packages/webui/src/public/bus.js: OK
packages/webui/tests/bus-browser.test.mjs: OK
packages/webui/tests/bus.test.mjs: OK
@@ -0,0 +1,91 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (174.296301ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (343.006282ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (315.245487ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (650.035825ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (308.088429ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (359.713005ms)
✔ task action subjects and linked decision trail are complete and ordered (331.696051ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (160.940716ms)
✔ business isolation includes inherited object names and cross-business message references (310.527465ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (466.789147ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (145.643876ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (215.468908ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (139.789942ms)
✔ both arbiters require human resolution when their cross-role route is themselves (199.561263ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (3.396001ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (12.520871ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (7.115523ms)
✔ expiry refuses use and env references never become client data (1.598587ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (6.291056ms)
✔ opaque tokens shorter than 16 characters refuse before use (3.655408ms)
✔ roles lists open decisions routed to a role, and never the human inbox (261.152654ms)
✔ recent lists closed decisions of every route, newest close first, with how each closed (363.303086ms)
✔ recent stops at RECENT rows (1829.735569ms)
✔ every row is what trail already gives the same session for that id (391.336494ms)
✔ a role session can't list decisions, so it can't read another role's open decisions (647.611132ms)
✔ decisions is a read: one business, strict arguments, nothing written (373.694874ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (156.043937ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (165.019157ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (206.43812ms)
✔ endLaunch leaves a claim another run took alone (207.183829ms)
✔ refuse records action.refused against the caller with the code only (302.67497ms)
✔ launches off refuses role.launch with launch-revoked until launches on (520.083612ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (510.509834ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (9.444502ms)
✔ process reader gets own kernel identity without exposing environment values (3.344547ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (2.167083ms)
✔ real pid 1 remains inspectable when its environment is protected (0.615375ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (236.544443ms)
✔ missing and foreign-business citations refuse and roll back message and grant (268.628699ms)
✔ cross-role sends still need a matching resolved decision and consume it once (279.325314ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (245.619623ms)
✔ startup token refusal returns safe code without value or partial listening broker (53.708461ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (247.669476ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (221.533169ms)
✔ trusted host registers later launches; socket clients never have a registration verb (660.145876ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (97.67311ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (358.170086ms)
✔ v3b prototype refusals, views and append-only mutations (1839.297938ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (308.616906ms)
✔ another run cannot consume an approval; a failed check leaves it usable (288.290129ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (228.173145ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (893.540551ms)
✔ class drift gated to cross-role refuses before consumption (458.933673ms)
✔ class drift cross-role to gated refuses before consumption (485.671212ms)
✔ class drift gated to within-role refuses before consumption (314.087389ms)
✔ class drift cross-role to within-role refuses before consumption (421.253693ms)
✔ class drift within-role to gated refuses before consumption (202.694147ms)
✔ class drift within-role to cross-role refuses before consumption (192.462176ms)
✔ message.send consumes approval and prevents a later send or authorize (192.996937ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (260.913993ms)
✔ creates private WAL store and excludes a second writer until explicit close (151.485052ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (208.102989ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (220.863553ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (228.228095ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (312.964785ms)
✔ async transactions refuse before invoking their function (379.311304ms)
✔ tasks rows carry priority and requirement for every session that reads tasks (390.857173ms)
✔ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (394.863888ms)
✔ an empty business and one with snapshots but no task.created read cleanly (320.277311ms)
✔ recordTask keeps sync reads and a role write apart (201.473518ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (147.47186ms)
✔ a bad entry refuses the whole record (135.729423ms)
✔ taskView reads the projection for one business (150.972983ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (471.337904ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (766.565026ms)
✔ without an adapter the server refuses every task verb (461.189217ms)
✔ the runtime refuses an invalid adapter and closes a valid one (297.711316ms)
✔ the process loads the S3 adapter from plain-data trackers (572.225242ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (182.026154ms)
✔ two wire claims serialize; a lost reply never automatically retries (256.975151ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (137.32736ms)
✔ client preserves UTF-8 when a response divides a multibyte character (13.085301ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (162.326848ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 4462.072351
@@ -0,0 +1,16 @@
#!/bin/bash
# Row 58 gate: the brief's suites (bus, tasks, webui) plus every scripts/test-*.sh, one at a time,
# in a detached worktree of the current HEAD with build.patch applied.
export TMPDIR=~/darkwing-scratch/r58/tmp
G=~/darkwing-scratch/r58/gate; O=~/darkwing-scratch/r58/out/gate; mkdir -p $O; rm -f $O/summary.txt; cd $G
# node_modules is a link to the main checkout's; test-discord needs node_modules/.bin/pi.
[ -x node_modules/.bin/pi ] || { echo "node_modules not linked" >> $O/summary.txt; exit 1; }
for s in bus tasks webui; do
env -u NODE_TEST_CONTEXT node --test "packages/$s/tests/*.test.mjs" > $O/$s-tests.log 2>&1; echo "$s rc=$?" >> $O/summary.txt
done
for s in scripts/test-*.sh; do
n=$(basename $s .sh)
if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $s > $O/$n.log 2>&1; else bash $s > $O/$n.log 2>&1; fi
echo "$n rc=$?" >> $O/summary.txt
done
echo DONE >> $O/summary.txt
@@ -0,0 +1,13 @@
bus rc=0
tasks rc=0
webui rc=0
test-auth rc=0
test-conductor rc=0
test-config rc=0
test-discord rc=0
test-extension-package rc=0
test-foundation rc=0
test-queue rc=0
test-release rc=0
test-task rc=0
DONE
@@ -0,0 +1,79 @@
✔ the boot config is checked before anything starts (100.208812ms)
✔ a business with no tracker entry refuses task verbs (131.404895ms)
✔ credential.expiring and .expired are recorded once per instance (288.714629ms)
✔ a token file that changes on disk records credential.changed (104.313151ms)
✔ autostart polls, reconciles and retries a startup the tracker was down for (233.576715ms)
✔ a refusal a restart must clear is not retried by the poll (114.672832ms)
✔ a poll that fires while two are queued is dropped (104.669646ms)
✔ close waits for a running verb and refuses one that has not started (200.265835ms)
✔ the bundled Vikunja is the pinned upstream image the runbook names (1.672948ms)
✔ every published port is on 127.0.0.1, and no secret is in the file (1.181256ms)
✔ the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent (449.868334ms)
✔ the project list holds recorded project objects in the recorded list envelope (119.339078ms)
✔ the recorded task bodies pass the checks S3 applies to every read (1.067817ms)
✔ the client works against the fake over real HTTP with the platform fetch (371.652461ms)
✔ a correct install starts, and the first reconcile records tasks that already exist (144.272318ms)
✔ verbs refuse while a business is starting and after startup refused it (141.755188ms)
✔ startup refuses a token that can do more than its role needs (544.807904ms)
✔ startup refuses an unsupported version and flags an untested one (330.856873ms)
✔ startup refuses a board that the runbook did not install (370.431419ms)
✔ startup refuses a project the sync bot cannot read (159.048155ms)
✔ startup refuses a configured label the pm bot cannot see (93.8654ms)
✔ startup refuses an expired credential and a missing sync credential (168.359766ms)
✔ an unreachable tracker refuses with tracker-unavailable (92.029783ms)
✔ an edit in the UI is recorded once, with the fields that changed (244.274002ms)
✔ a move between open buckets is seen on the board, though updated does not change (216.953978ms)
✔ a person's comment is counted and a bot's is not (401.544165ms)
✔ the hourly reconcile catches a comment through comment_count (241.866718ms)
✔ a task closed in the UI leaves the open view with its done bucket (377.893029ms)
✔ a task that leaves the board is recorded as deleted, moved or out of reach (350.418814ms)
✔ a poll that read before a verb wrote does not overwrite the verb (180.479449ms)
✔ a tracker fault during a tick is reported and the next tick catches up (164.843975ms)
✔ a malformed answer refuses the tick with tracker-shape (142.738851ms)
✔ no token value reaches the database, the log or a refusal (281.352052ms)
✔ the first look at a task counts only comments inside the window (139.326447ms)
✔ with descendants off the adapter reads no project list and no child, as in v0 (139.555066ms)
✔ a three-level tree is read per project, and a project outside it is not (130.069472ms)
✔ a watch-only child keeps the business ready, and a failed child read refuses as transient (247.612384ms)
✔ a root without the installed buckets refuses with descendants on (104.903462ms)
✔ the project list refuses without read_all, and skips archived projects and saved filters (309.634411ms)
✔ descendants must be true or false (97.483334ms)
✔ a move inside the tree is a moved tombstone plus a snapshot under the new ref (189.581872ms)
✔ a child made after startup joins the tree at the next reconcile (153.088527ms)
✔ a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history (197.714088ms)
✔ a child deleted or hidden between reconciles drops out at the next tick, and the root still refuses (237.555435ms)
✔ a reconcile that fails keeps the previous tree (129.94486ms)
✔ a closed task the cursor missed takes its own project's done bucket (134.588777ms)
✔ closed tasks read at startup take their own project's done bucket (96.11233ms)
✔ a closed task the cursor returns takes its own project's done bucket (134.17724ms)
✔ a task moved out of a child that leaves the tree at the same time is moved, not no-access (143.184339ms)
✔ a task in a bucket outside the five keeps its raw bucket id (112.941716ms)
✔ bucket 0 on a watch-only task never becomes a bucket or a state (208.792424ms)
✔ a child whose kanban view is deleted still refuses the tick, and the reconcile reads it watch-only (143.794754ms)
✔ a child dropped at a tick comes back at the next reconcile once the bot reads it again (122.34508ms)
✔ task.create needs a recorded human request and a requirement id (221.502964ms)
✔ only labels named in the business file can be written (283.717269ms)
✔ task.schedule sets and clears a due date and relations (408.864349ms)
✔ assign and reassign move the role bots and record task.assigned (323.62119ms)
✔ task.update.assigned is for the assignee and records task.state (315.101254ms)
✔ a wrong expected digest records task.conflict and writes nothing (249.286928ms)
✔ a cross-role verb needs a resolved decision, used once (242.349666ms)
✔ task.close needs a verdict; after it every verb refuses with task-done (224.338739ms)
✔ a lost answer is settled by a re-read and never retried (251.653339ms)
✔ a create whose answer is lost is reported uncertain, and the poll finds the task (187.103147ms)
✔ a task the sync bot cannot read refuses and records nothing (174.91843ms)
✔ verbs and polls for one business run one at a time (222.178174ms)
✔ a due date with milliseconds is written to the second (227.828746ms)
✔ every write landed and the final read failed: the verb succeeds and records what it wrote (157.643377ms)
✔ some writes landed and the final read failed: write-uncertain, and nothing is recorded (105.90067ms)
✔ a create whose final read fails succeeds and records task.created (105.899049ms)
✔ an edit between the last write and the final read shows as external on the next poll (129.748344ms)
✔ task.created is recorded when a later label write fails (100.075318ms)
ℹ tests 71
ℹ suites 0
ℹ pass 71
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 4094.414646
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,55 @@
Note: switching to 'f2f2861b7f736b6b3623ef85bdd261c2394671b2'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
Not currently on any branch.
nothing to commit, working tree clean
Note: switching to 'f2f2861b7f736b6b3623ef85bdd261c2394671b2'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
OK dry-run committed nothing
OK apply: allowed change exits 0 (exit 0)
OK apply: attribution in commit subject
OK apply: target tree clean after commit
OK disallowed path refused (exit 1)
OK disallowed path: target untouched
OK syntax gate refused broken .mjs (exit 1)
OK syntax gate: target untouched
OK suite failure refused (exit 1)
OK suite failure: target reverted to clean
OK disabled policy refused (exit 2)
OK disabled policy: target untouched
OK failed run refused (exit 1)
OK failed run: target untouched
OK missing run exits 4 (exit 4)
OK invalid policy exits 2 (exit 2)
selftest: 17 passed, 0 failed
@@ -0,0 +1,26 @@
OK absent adapter defaults to pi
OK adapter mock validates (exit 0)
OK unsupported adapter exits 2 (exit 2)
OK env exports adapter
OK bootstrap creates default when absent (exit 0)
OK bootstrap wrote config file
OK bootstrap is idempotent on existing config (exit 0)
OK bootstrap did not rewrite existing config
OK validate missing config exits 3 (exit 3)
OK malformed JSON exits 2 (exit 2)
OK unsupported configVersion exits 2 (exit 2)
OK unknown top-level key exits 2 (exit 2)
OK unknown execution key exits 2 (exit 2)
OK unsupported backend exits 2 (exit 2)
OK unsupported environment exits 2 (exit 2)
OK relative dataRoot exits 2 (exit 2)
OK non-canonical dataRoot exits 2 (exit 2)
OK filesystem root dataRoot exits 2 (exit 2)
OK home directory dataRoot exits 2 (exit 2)
OK dataRoot containing config dir exits 2 (exit 2)
OK control character in provider exits 2 (exit 2)
OK symlinked config file exits 2 (exit 2)
OK env exports resolve correctly
OK failed validation modified nothing
selftest: 24 passed, 0 failed
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,21 @@
OK initial ordinary-file install
OK installed tree matches canonical source
OK installed tree has no symlinks
OK check detects installation drift
OK sync refuses to overwrite installation drift
OK check detects an extra destination file
OK check detects an extra destination directory
OK check rejects a destination symlink
OK sync accepts a canonical source update
OK updated installation matches canonical source
scripts/test-extension-package.sh: line 14: 2242379 Killed "$@" > /dev/null 2>&1
OK forced interruption kills the replacing process
OK next invocation recovers old consistent installation
OK interrupted replacement rolled back
OK sync succeeds after interruption recovery
OK unlocked stale lock file does not block
OK active lock refuses a concurrent sync
OK source symlink fails closed
OK nested second entrypoint fails closed
extension package selftest: 18 passed, 0 failed
@@ -0,0 +1,53 @@
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
OK syntax: scripts/foundation-inspect.mjs
OK syntax: scripts/foundation/strict-json.mjs
OK syntax: scripts/foundation/canonical.mjs
OK syntax: scripts/foundation/resolve.mjs
OK syntax: scripts/foundation/validate-record.mjs
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
OK syntax: scripts/foundation/canonical.test.mjs
OK syntax: scripts/foundation/cli.test.mjs
OK syntax: scripts/foundation/fixtures.test.mjs
OK syntax: scripts/foundation/resolve.test.mjs
OK syntax: scripts/foundation/strict-json.test.mjs
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
OK fixture generator runs
OK checked-in fixtures/bundles equal a fresh generation
OK checked-in fixtures/raw equal a fresh generation
OK checked-in fixtures/index.json equal a fresh generation
OK checked-in demo bundles equal a fresh generation
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test scripts/foundation/ (ℹ pass 80)
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
OK oracle: zero schema-column disagreements with the pinned checker
OK oracle: strict-only profile refusals are counted and asserted
OK demo: permitted read preview exits 0 (exit 0)
OK demo: permitted file.change preview exits 0 (exit 0)
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
OK demo: revoked registration is refused (exit 3) (exit 3)
OK demo: message is not authority (exit 3) (exit 3)
OK usage: no arguments exits 2 (exit 2)
OK io: missing file exits 4 (exit 4)
OK io: directory exits 4 (exit 4)
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
OK bound: oversize fixture exits 2 (exit 2)
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
OK text output starts with the disclaimer
OK json output is valid JSON with result allowed and exactly the charter §7 fields
OK json golden matches byte-for-byte
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
OK canary never printed (bundle run and credential-file run)
OK a non-bundle JSON file is refused at the shape gate, not read into output
OK no field of the non-bundle file is echoed
selftest: 44 passed, 0 failed
@@ -0,0 +1,35 @@
toolchain: node v26.8.1, git version 2.55.0
OK syntax: packages/queue/src/cli.mjs
OK syntax: packages/queue/src/errors.mjs
OK syntax: packages/queue/src/io.mjs
OK syntax: packages/queue/src/lock.mjs
OK syntax: packages/queue/src/queue.mjs
OK syntax: packages/queue/src/review.mjs
OK syntax: packages/queue/src/store.mjs
OK syntax: packages/queue/tests/commit.test.mjs
OK syntax: packages/queue/tests/data.test.mjs
OK syntax: packages/queue/tests/dispatch.test.mjs
OK syntax: packages/queue/tests/helpers.mjs
OK syntax: packages/queue/tests/lock.test.mjs
OK syntax: packages/queue/tests/migration.test.mjs
OK syntax: packages/queue/tests/review.test.mjs
OK syntax: packages/queue/tests/store.test.mjs
OK syntax: packages/queue/tests/write.test.mjs
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
OK syntax: scripts/queue-commit.sh
OK syntax: scripts/git-hooks/pre-commit
OK syntax: scripts/mosaic
OK queue-commit.sh, the guard and scripts/mosaic are executable
OK packages/queue declares no dependencies
ℹ tests 150
ℹ pass 150
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r58/gate) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,16 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
OK status safe on empty state (exit 0)
OK status created no pointer
OK fault-injected activation refuses (exit 1)
OK refused activation wrote no pointer
OK refusal logged exactly once with valid fields
OK healthy activation succeeds (exit 0)
OK pointer written with valid fields
OK repeat activation succeeds (log grows) (exit 0)
OK log is append-only across activations
OK rollback without previous refuses (exit 1)
selftest: 14 passed, 0 failed
@@ -0,0 +1,32 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
skip adapter seam cases (docker daemon unavailable)
skip workspace/capability cases (docker daemon unavailable)
skip live task cases (docker unavailable)
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
OK no agent identity on headless run
selftest: 26 passed, 0 failed
@@ -0,0 +1,49 @@
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (6561.92218ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (8652.977054ms)
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (9402.853483ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (383.653418ms)
✔ decisions route serves the roles and recent lists from the reader view (397.233539ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (300.364963ms)
✔ bus refusals map to statuses and never carry a path or a stack (195.330377ms)
✔ humanCall speaks the human transport protocol without blocking the server (2685.907663ms)
✔ busReader takes --business, else the live bus host, else refuses (518.612191ms)
✔ humanCall kills a transport that runs past its timeout (547.154367ms)
✔ serve refuses a --business value that is not a business id (659.088475ms)
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (6474.338594ms)
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (3689.648064ms)
✔ conversation view: a newer session with no readable history keeps the marker (2751.485722ms)
✔ conversation view: seats without history say so and offer no reply (2065.566145ms)
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (6206.855618ms)
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (57413.788144ms)
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (2.996407ms)
✔ credential state from the date alone, as the bus states it (0.309707ms)
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (2742.128278ms)
✔ queue: a refused read fails closed with the store's text, redacted (2078.396556ms)
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (4254.745108ms)
✔ business: names, vars on the allowlist, authority, credential metadata only (100.44112ms)
✔ business: missing or invalid file refuses with the module's text, redacted (13.973272ms)
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (13.932099ms)
✔ settings: release, board, loopback address, notifier binding name only (677.704624ms)
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (6660.681779ms)
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (25216.516956ms)
✔ loopback host and board origin fail closed (13.778016ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (178.313944ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (176.017018ms)
✔ unreachable board reports URL; CLI rejects unsupported options (2276.784001ms)
✔ tokens.css and icons.svg are byte copies of docs/design (13.136971ms)
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (13.333094ms)
✔ shipped strings are unchanged, and the web keeps its write boundary (7.351991ms)
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (11479.249425ms)
✔ five states on Board, Inbox, Tasks, Agents and Trail (3931.883085ms)
✔ Queue, Business and Settings in their five states, with no move button (9250.948248ms)
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3547.734575ms)
✔ the server log names no config path when the system config refuses (426.523147ms)
ℹ tests 40
ℹ suites 0
ℹ pass 40
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 58109.727949
@@ -0,0 +1,38 @@
#!/bin/bash
# Row 58 (#1547) mutants. Each edits one source file in the build worktree, runs the test file that
# should fail it, then restores the file from the worktree index, which holds the candidate. Only
# the one test file runs, since other seats' suites shared the machine; the gate runs every suite.
export TMPDIR=~/darkwing-scratch/r58/tmp
cd ~/darkwing-scratch/r58/wt
B=packages/bus/src/broker.mjs; W=packages/webui/src/public/bus.js
BT=packages/bus/tests/task-read.test.mjs; WE=packages/webui/tests/bus.test.mjs; WB=packages/webui/tests/bus-browser.test.mjs
run() { # name file sed-expr test-file
sed -i "$3" "$2"
if git diff --quiet -- "$2"; then echo "$1: MUTATION DID NOT APPLY"; return; fi
env -u NODE_TEST_CONTEXT node --test --test-timeout=60000 "$4" > ~/darkwing-scratch/r58/tmp/mut-$1.log 2>&1
echo "$1: rc=$? $(grep -E '^ℹ (pass|fail) ' ~/darkwing-scratch/r58/tmp/mut-$1.log | tr '\n' ' ')"
grep -E '^✖ ' ~/darkwing-scratch/r58/tmp/mut-$1.log | sed 's/ ([0-9.]*ms)$//; s/^/ /' | sort -u | head -4
git checkout -q -- "$2"
}
# The broker's 'tasks' rows.
run tasks-verb-unchanged $B 's/ if (verb === .tasks.) return this.#taskRows(s.business);/ if (verb === "tasks") return this.#store.all("SELECT * FROM task_current WHERE business=? ORDER BY task_ref", s.business).map((r) => ({ ...r, fields: JSON.parse(r.fields) }));/' $BT
run no-priority $B 's/ const priority = fields.priority ?? null;/ const priority = null;/' $BT
run zero-priority-null $B 's/ const priority = fields.priority ?? null;/ const priority = fields.priority || null;/' $BT
run tombstone-priority-zero $B 's/ const priority = fields.priority ?? null;/ const priority = fields.priority ?? 0;/' $BT
run requirement-by-ref $B "s/ const taskId = (ref) => ref.slice(ref.indexOf('\/') + 1);/ const taskId = (ref) => ref;/" $BT
run requirement-last-wins $B 's/ if (!requirement.has(taskId(e.subject))) requirement.set/ requirement.set/' $BT
run requirement-any-business $B "s/ FROM events WHERE business=? AND kind='task.created' ORDER BY seq\",/ FROM events WHERE (business=? OR 1) AND kind='task.created' ORDER BY seq\",/" $BT
run requirement-undefined $B 's/requirement: requirement.get(taskId(r.task_ref)) ?? null }/requirement: requirement.get(taskId(r.task_ref)) }/' $BT
run task-view-adds-keys $B 's/ const parse = (r) => ({ ...r, fields: JSON.parse(r.fields) });/ const parse = (r) => ({ ...r, fields: JSON.parse(r.fields), priority: null, requirement: null });/' $BT
# The endpoint passes both keys through.
run endpoint-drops-keys $B 's/ return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };/ return { ...r, fields };/' $WE
# The Tasks view.
run list-requirement-from-trail $W 's/ requirement: row.requirement ?? created?.body?.requirement ?? null,/ requirement: created?.body?.requirement ?? null,/' $WB
run no-requirement-column $W "s/\['Task', 'Requirement', 'Assignees'/['Task', 'Assignees'/; s|<td>\${t.requirement ? txt(t.requirement) : '<span class=\"muted\">none</span>'}</td>||" $WB
run priority-default-zero $W 's/priority: row.priority ?? shape.priority ?? null,/priority: row.priority ?? shape.priority ?? 0,/' $WB
run tombstone-no-fallback $W 's/priority: row.priority ?? shape.priority ?? null,/priority: row.priority ?? null,/' $WB
run detail-priority-raw $W 's|<dt>Priority</dt><dd>${priority(t.priority)}</dd>|<dt>Priority</dt><dd>${esc(PRIORITY[t.priority] \|\| t.priority)}</dd>|' $WB
run list-priority-raw $W 's|<td>${due(t.due)}</td><td>${priority(t.priority)}</td>|<td>${due(t.due)}</td><td>${esc(PRIORITY[t.priority] \|\| t.priority)}</td>|' $WB
# Expected to survive: for a live row the bus priority is fields.priority, which shape is too.
run ignore-row-priority $W 's/priority: row.priority ?? shape.priority ?? null,/priority: shape.priority ?? null,/' $WB
git diff --quiet -- $B $W && echo "restored: sources match the candidate index" || echo "NOT RESTORED"
@@ -0,0 +1,60 @@
tasks-verb-unchanged: rc=1 ℹ pass 0 ℹ fail 3
✖ an empty business and one with snapshots but no task.created read cleanly
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
no-priority: rc=1 ℹ pass 1 ℹ fail 2
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
zero-priority-null: rc=1 ℹ pass 1 ℹ fail 2
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
tombstone-priority-zero: rc=1 ℹ pass 0 ℹ fail 3
✖ an empty business and one with snapshots but no task.created read cleanly
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
requirement-by-ref: rc=1 ℹ pass 1 ℹ fail 2
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
requirement-last-wins: rc=1 ℹ pass 1 ℹ fail 2
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
requirement-any-business: rc=1 ℹ pass 2 ℹ fail 1
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
requirement-undefined: rc=1 ℹ pass 0 ℹ fail 3
✖ an empty business and one with snapshots but no task.created read cleanly
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values
task-view-adds-keys: rc=1 ℹ pass 2 ℹ fail 1
✖ failing tests:
✖ tasks rows carry priority and requirement for every session that reads tasks
endpoint-drops-keys: rc=1 ℹ pass 7 ℹ fail 1
✖ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape
✖ failing tests:
list-requirement-from-trail: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
no-requirement-column: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
priority-default-zero: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
tombstone-no-fallback: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
detail-priority-raw: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
list-priority-raw: rc=1 ℹ pass 0 ℹ fail 1
✖ failing tests:
✖ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes
ignore-row-priority: rc=0 ℹ pass 1 ℹ fail 0
restored: sources match the candidate index
@@ -0,0 +1,171 @@
✖ tasks rows carry priority and requirement for every session that reads tasks (457.263216ms)
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (404.191686ms)
✖ an empty business and one with snapshots but no task.created read cleanly (88.520491ms)
ℹ tests 3
ℹ suites 0
ℹ pass 0
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1017.801953
✖ failing tests:
test at packages/bus/tests/task-read.test.mjs:98:1
✖ tasks rows carry priority and requirement for every session that reads tasks (457.263216ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
[
'vikunja:3/10',
+ undefined,
+ undefined
- null,
- 'REQ-SLICE-3'
],
[
'vikunja:3/11',
+ undefined,
+ undefined
- null,
- 'REQ-MOVE-1'
],
[
'vikunja:3/7',
+ undefined,
+ undefined
- 3,
- 'REQ-SLICE-1'
],
[
'vikunja:3/8',
+ undefined,
+ undefined
- 5,
- 'REQ-SLICE-2'
],
[
'vikunja:3/9',
+ undefined,
+ undefined
- 0,
- null
],
[
'vikunja:4/11',
+ undefined,
+ undefined
- 2,
- 'REQ-MOVE-1'
]
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r58/wt/packages/bus/tests/task-read.test.mjs:101:57)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ [ 'vikunja:3/10', undefined, undefined ], [ 'vikunja:3/11', undefined, undefined ], [ 'vikunja:3/7', undefined, undefined ], [ 'vikunja:3/8', undefined, undefined ], [ 'vikunja:3/9', undefined, undefined ], [ 'vikunja:4/11', undefined, undefined ] ],
expected: [ [ 'vikunja:3/10', null, 'REQ-SLICE-3' ], [ 'vikunja:3/11', null, 'REQ-MOVE-1' ], [ 'vikunja:3/7', 3, 'REQ-SLICE-1' ], [ 'vikunja:3/8', 5, 'REQ-SLICE-2' ], [ 'vikunja:3/9', 0, null ], [ 'vikunja:4/11', 2, 'REQ-MOVE-1' ] ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/bus/tests/task-read.test.mjs:117:1
✖ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (404.191686ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
[
'vikunja:3/10',
+ undefined,
+ undefined
- null,
- 'REQ-SLICE-3'
],
[
'vikunja:3/11',
+ undefined,
+ undefined
- null,
- 'REQ-MOVE-1'
],
[
'vikunja:3/7',
+ undefined,
+ undefined
- 3,
- 'REQ-SLICE-1'
],
[
'vikunja:3/8',
+ undefined,
+ undefined
- 5,
- 'REQ-SLICE-2'
],
[
'vikunja:3/9',
+ undefined,
+ undefined
- 0,
- null
],
[
'vikunja:4/11',
+ undefined,
+ undefined
- 2,
- 'REQ-MOVE-1'
]
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r58/wt/packages/bus/tests/task-read.test.mjs:125:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ [ 'vikunja:3/10', undefined, undefined ], [ 'vikunja:3/11', undefined, undefined ], [ 'vikunja:3/7', undefined, undefined ], [ 'vikunja:3/8', undefined, undefined ], [ 'vikunja:3/9', undefined, undefined ], [ 'vikunja:4/11', undefined, undefined ] ],
expected: [ [ 'vikunja:3/10', null, 'REQ-SLICE-3' ], [ 'vikunja:3/11', null, 'REQ-MOVE-1' ], [ 'vikunja:3/7', 3, 'REQ-SLICE-1' ], [ 'vikunja:3/8', 5, 'REQ-SLICE-2' ], [ 'vikunja:3/9', 0, null ], [ 'vikunja:4/11', 2, 'REQ-MOVE-1' ] ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/bus/tests/task-read.test.mjs:146:1
✖ an empty business and one with snapshots but no task.created read cleanly (88.520491ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
[
'vikunja:5/1',
+ undefined,
+ undefined
- null,
- null
]
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r58/wt/packages/bus/tests/task-read.test.mjs:150:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ [ 'vikunja:5/1', undefined, undefined ] ],
expected: [ [ 'vikunja:5/1', null, null ] ],
operator: 'deepStrictEqual',
diff: 'simple'
}