diff --git a/agents/filbert/work/queue-57/BUILD.md b/agents/filbert/work/queue-57/BUILD.md new file mode 100644 index 000000000..74fbad298 --- /dev/null +++ b/agents/filbert/work/queue-57/BUILD.md @@ -0,0 +1,178 @@ +# Row 57 (#1546): the bus reads decisions routed to roles and recent decisions, candidate packet + +Author: Filbert. Reviewers: Darkwing and Dewey. Brief: "Bus read: decisions +routed to roles and recent decisions" in +`docs/plans/2026-10-10_design-implementation.md`. Base: `958668a8476250edab8025a48a5c553dfd75f99b` (`base.txt`). +The feature is uncommitted; only this packet is committed. I pushed nothing. + +## Files + +`files.txt` lists the 8 repository paths. `candidate-manifest.sha256` has +their sha256. `build.patch` is `git diff --cached --binary 958668a8` over them: +8 files, +297/−17. + +| File | Change | +|---|---| +| `packages/bus/src/broker.mjs` | new verb `decisions`, `{list: 'roles' \| 'recent'}`; `RECENT = 50`; the reader allowlist adds it | +| `packages/bus/src/views.mjs` | `decisions(list)` in the Q1 client module | +| `packages/bus/tests/decisions.test.mjs` | new: 6 tests, listed below | +| `packages/bus/README.md` | "Human and reader paths" says what the verb returns and who may call it; the "Shared reads" example | +| `packages/webui/src/serve.mjs` | `GET /api/bus/decisions?list=roles\|recent`; any other list is 400 and never reaches the bus | +| `packages/webui/src/bus.mjs` | the header comment only; the reader already passes any `views()` verb through | +| `packages/webui/tests/bus.test.mjs` | one new test for the route; the 405 test and the `busReader` test cover it too | +| `packages/webui/README.md` | the bus read list and the port exposure note | + +## Scope + +The brief owns `packages/bus/`, `packages/webui/src/bus.mjs` and its tests. +Two files outside that list changed: + +- `packages/webui/src/serve.mjs`. The brief's "its webui endpoint" is a + route, and the routes live there, not in `bus.mjs`. The change adds the + verb to `BUS_VERBS` and checks `?list=` before the bus is called. +- `packages/webui/README.md`. It lists every `/api/bus/` route and what the + port exposes, so a new route without it would leave the README wrong. + +No Inbox tab uses the endpoint yet. The brief ships the read and the +endpoint; the tabs are the design rows' work. + +## How it works + +`decisions` is a broker verb like `inbox`, in the same read transaction. + +- Who: human and reader sessions. A role session gets `human-required`, + claimed or not, and the refusal is recorded as `action.refused` like + every refusal. A reader still gets `read-only` for every write. +- Arguments: `{list}` and nothing else (`keys(a, ['list'], ['list'])`). + Any other list is `invalid-request`. +- `roles`: the business's open decisions with `route_to <> 'human'`, in + `seq` order. Open means no `resolved`, `withdrawn` or `expired` event, + the same rule as `inbox` and the schema's `inbox` view. The human's own + open decisions stay in `inbox`. Unlike `inbox`, gated decisions are not + put first: the list spans roles, and `route_to` is on each row. +- `recent`: the business's closed decisions of every route, newest close + first (`decision_events.seq DESC`), at most `RECENT` (50). Each row has + `closed`, the closing `decision_events` row, from the same `#closed()` + that the authority check and `decision.resolve` use. The schema trigger + `decisions_resolve_once` allows one close per decision, so the join + can't repeat a row. +- Each row is `#decisionView(d)`, the shape `inbox` returns: options + parsed, `blocking` a boolean, and the authorization context. + +### What a reader may read + +The brief says the reader's authority doesn't widen. Per row, it doesn't: +`trail()` already gives a reader or human session the same +decision row and its closing event, and test 4 checks every listed row +against it. What is new is the listing. Before this row a reader needed +the id. Decision ids are random UUIDs. A decision with a `task_ref` could +already be found through `tasks` and then the task's trail, but one +without (a within-role decision, or a scope change with only a target) +could not. + +The WebUI port is where that matters. F1 on #1522 recorded that anything +that can connect to the Console's port reads what a reader capability +reads, including a T3 seat with `curl` and a managed S6 session, and slice +1 accepted that. With this row that includes both lists, so a role session +that can reach a running Console's port can list other roles' open +decisions there, though not through its own bus capability. The README's +exposure note now says so. The brief's test is on the bus capability +(test 5); closing the port is outside this row, as it was for S5. + +## Tests + +`packages/bus/tests/decisions.test.mjs`, each for both a human and a +reader session where it applies: + +1. `roles` lists the open decisions routed to cto and pm, never the + human's, with the `inbox` shape; `inbox` still lists only the human's. +2. `recent` lists closed decisions of every route, newest close first, + with how each closed: resolved by the human (via `cli`, with its + note), resolved by a role, withdrawn, expired, and within-role. + `roles` is then empty. +3. `recent` stops at `RECENT` rows, newest first. +4. Every row of both lists equals the decision row, and its `closed` the + closing event, that `trail` gives the same session for that id. +5. A role session (cto, pm, coder, and an unclaimed launch) gets + `human-required` for `roles`, `recent` and an unknown list, and each + refusal is recorded. Each role's `inbox` stays its own. A role in + another business is refused too. +6. One business only (a reader of `other` sees only `other`'s rows), and + strict arguments (`{}`, `all`, `limit`, an array, `Recent`). A + successful read writes no row in `events`, `decisions`, + `decision_events`, `messages`, `deliveries` or `role_claims`. A reader + still can't send `decision.seen`. + +`packages/webui/tests/bus.test.mjs`: + +- New: the route serves both lists from a real broker through the reader + view, with `no-store`, `{rows, at}`, and a hostile question unescaped + in the JSON. A missing, empty, unknown, wrong-case or NUL-suffixed list, + or `?subject=` instead, is 400 and never calls the bus. +- The 405 test sends every non-GET method to the new route too. +- The `busReader` test checks `decisions('recent')` sends + `{business, verb: 'decisions', args: {list: 'recent'}}` to the transport. + +### Mutants + +`out/mutants/run.mjs` edits one source file in a second worktree, runs the +bus and webui suites, and restores the file (`out/mutants/result.txt`): + +| Mutant | Change | Killed by | +|---|---|---| +| `role-allowed` | a role session may call `decisions` | bus test 5 | +| `roles-with-human` | `roles` includes the human's decisions | bus test 1; webui route test | +| `roles-with-closed` | `roles` includes closed decisions | bus tests 1, 2, 6; webui route test | +| `roles-other-business` | `roles` ignores the business | bus test 6 | +| `recent-other-business` | `recent` ignores the business | bus test 6 | +| `recent-oldest-first` | `recent` oldest close first | bus tests 2, 3 | +| `recent-by-raise` | `recent` ordered by raise, not close | bus test 2 | +| `recent-no-limit` | no `LIMIT` | bus test 3 | +| `recent-limit-plus-one` | `LIMIT RECENT + 1` | bus test 3 | +| `recent-resolved-only` | `recent` drops withdrawn and expired | bus test 2 | +| `recent-no-closed` | rows lose `closed` | bus test 2; webui route test | +| `loose-keys` | `limit` accepted | bus test 6 | +| `unknown-list-empty` | an unknown list returns `[]` | bus test 6 | +| `reader-refused` | the reader allowlist drops `decisions` | bus tests 1-4 and 6; webui route test | +| `web-no-list-check` | the route passes any `?list=` to the bus | webui route test | +| `web-no-verb` | `decisions` not in `BUS_VERBS` | webui route test and 405 test | +| `views-fixed-list` | `views().decisions` ignores its argument | webui route test and `busReader` test | + +All 17 are killed. `roles-other-business` first matched the `inbox` query +too; I narrowed the pattern and reran it with `roles-with-human` +(`result-rerun.txt`). In the first run, `roles-with-human`'s webui run also +hit the 600-second timeout in unrelated conversation-view tests while the +machine was loaded; the rerun shows the route test failing on its own. + +## Gate + +Run on `958668a8` with the candidate applied, sequentially, `TMPDIR` in my +scratch and `DOCKER_HOST` pointed at a missing socket (`out/gate/`): + +| Suite | Result | +|---|---| +| `node --test packages/bus/tests/*.test.mjs` | rc 0, 80 pass, 0 fail | +| `node --test tests/` in `packages/webui` | rc 0, 40 pass, 0 fail | +| `test-auth.sh` | rc 0, 15 passed | +| `test-conductor.sh` | rc 0, 17 passed | +| `test-config.sh` | rc 0, 24 passed | +| `test-discord.sh` | rc 0, 66 passed | +| `test-extension-package.sh` | rc 0, 18 passed | +| `test-foundation.sh` | rc 0, 44 passed | +| `test-queue.sh` | rc 0, 27 passed | +| `test-release.sh` | rc 0, 4 passed; the Docker cases skip without a daemon | +| `test-task.sh` | rc 0, 26 passed | + +The manifest checks OK after the gate. Sage's rerun with Docker is the +gate of record. + +## Things a reviewer should know + +- `RECENT` is fixed at 50 and the verb takes no limit. The design's Recent + tab shows one page; a limit argument would be a new input to validate + for no current caller. +- The `recent` query reads `decision_events` once per row through + `#closed()`, as `inbox` does. At 50 rows that is 51 indexed reads in one + transaction. +- A decision raised and closed in the same call (within-role with a + `choice`) appears only in `recent`, never in `roles`. diff --git a/agents/filbert/work/queue-57/base.txt b/agents/filbert/work/queue-57/base.txt new file mode 100644 index 000000000..19c29660e --- /dev/null +++ b/agents/filbert/work/queue-57/base.txt @@ -0,0 +1 @@ +958668a8476250edab8025a48a5c553dfd75f99b diff --git a/agents/filbert/work/queue-57/build.patch b/agents/filbert/work/queue-57/build.patch new file mode 100644 index 000000000..4f3dc92ee --- /dev/null +++ b/agents/filbert/work/queue-57/build.patch @@ -0,0 +1,440 @@ +diff --git a/packages/bus/README.md b/packages/bus/README.md +index fff96910..5b57b7fc 100644 +--- a/packages/bus/README.md ++++ b/packages/bus/README.md +@@ -196,8 +196,17 @@ must not invoke the human CLI or work around its proof. + + Human resolutions and launch toggles append `human.input`. Agent capabilities + and read-only WebUI capabilities cannot reach these operations. Reader +-capabilities expose the human inbox plus tasks/agents/trail for one business; +-they do not confer mutation or resolution authority. ++capabilities expose the human inbox plus tasks/agents/trail/decisions for one ++business; they do not confer mutation or resolution authority. ++ ++`decisions` (row 57, #1546) is for human and reader sessions only; a role ++session gets `human-required`, so it can't list another role's open ++decisions. It takes `{list}`, nothing else. `roles` lists the business's open ++decisions routed to a role, never to the human, oldest first. `recent` lists ++its last `RECENT` (50) closed decisions of every route, newest close first, ++each with `closed`, the closing `decision_events` row. Every row is the ++decision as `trail` gives the same session for its id, so the lists add no ++data a reader couldn't already read one id at a time; they add the listing. + + ## Shared reads and adapter boundary + +@@ -207,6 +216,7 @@ import {views} from './src/views.mjs'; + const read = views(new Client({path,cap})); + await read.inbox(); await read.tasks(); await read.agents(); + await read.trail('vikunja:3/41'); ++await read.decisions('roles'); await read.decisions('recent'); + ``` + + S4 and S5 must import this client-only module. Neither opens `bus.sqlite`. +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index 0ebc8ff8..84db8799 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -38,6 +38,8 @@ const GATED = new Set([ + 'role.revoke', + ]); + const CLOSED = "('resolved','withdrawn','expired')"; ++// How many closed decisions `decisions` with list 'recent' returns, newest close first. ++export const RECENT = 50; + export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); + const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); + const POLL_KINDS = new Set([ +@@ -466,7 +468,8 @@ export class Broker { + if (!object(args)) fail('invalid-request'); + this.#secretCheck(args); + return this.#store.transaction(() => { +- if (s.reader && !['inbox', 'agents', 'tasks', 'trail'].includes(request.verb)) fail('read-only'); ++ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb)) ++ fail('read-only'); + const result = this.#dispatch(s, request.verb, args); + this.#secretCheck(result); + return result; +@@ -833,6 +836,27 @@ export class Broker { + .map((r) => ({ ...r, fields: JSON.parse(r.fields) })); + return this.#trail(s, identifier(a.subject)); + } ++ // Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and ++ // reader sessions only. Each row is what trail gives the same session for that id. ++ case 'decisions': { ++ if (!s.human && !s.reader) fail('human-required'); ++ keys(a, ['list'], ['list']); ++ if (a.list === 'roles') ++ return this.#store ++ .all( ++ `SELECT d.* FROM decisions d WHERE business=? AND route_to<>'human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY seq`, ++ s.business, ++ ) ++ .map((d) => this.#decisionView(d)); ++ if (a.list === 'recent') ++ return this.#store ++ .all( ++ `SELECT d.* FROM decisions d JOIN decision_events x ON x.decision=d.id AND x.op IN ${CLOSED} WHERE d.business=? ORDER BY x.seq DESC LIMIT ${RECENT}`, ++ s.business, ++ ) ++ .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) })); ++ fail('invalid-request'); ++ } + default: + fail('unknown-verb'); + } +diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs +index d161c2cd..2bffda6b 100644 +--- a/packages/bus/src/views.mjs ++++ b/packages/bus/src/views.mjs +@@ -5,5 +5,6 @@ export function views(client) { + tasks: () => client.call('tasks'), + agents: () => client.call('agents'), + trail: (subject) => client.call('trail', { subject }), ++ decisions: (list) => client.call('decisions', { list }), + }); + } +diff --git a/packages/bus/tests/decisions.test.mjs b/packages/bus/tests/decisions.test.mjs +new file mode 100644 +index 00000000..6626460b +--- /dev/null ++++ b/packages/bus/tests/decisions.test.mjs +@@ -0,0 +1,193 @@ ++// Row 57 (#1546): `decisions`, the read behind the Inbox tabs "Routed to roles" ++// and "Recent". Human and reader sessions read it; a role session can't. ++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, RECENT } from '../src/broker.mjs'; ++ ++const options = [ ++ { key: 'yes', text: 'Allow' }, ++ { key: 'no', text: 'Decline' }, ++]; ++const roles = { ++ pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } }, ++ cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, ++}; ++const businesses = { ++ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles }, ++ other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles }, ++}; ++function setup(t) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-decisions-')); ++ const store = new Store(root); ++ const b = new Broker({ store, businesses }); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const agent = (role, business = 'demo', run = `${business}-${role}-run`) => { ++ const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run }); ++ b.request(cap, { verb: 'role.claim' }); ++ return cap; ++ }; ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const reader = b.bindReader({ business: 'demo' }); ++ return { b, store, agent, human, reader }; ++} ++const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); ++const raise = (b, cap, action, extra = {}) => ++ call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra }); ++const list = (b, cap, which) => call(b, cap, 'decisions', { list: which }); ++ ++// Open: one routed to cto, one to pm, one to the human. Closed: one within-role. ++function seed(t) { ++ const x = setup(t); ++ const { b, agent } = x; ++ const coder = agent('coder'); ++ const cto = agent('cto'); ++ const pm = agent('pm'); ++ const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' }); ++ const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' }); ++ const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true }); ++ const within = raise(b, coder, 'message.send', { choice: 'yes' }); ++ assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to, within.route_to], ['cto', 'pm', 'human', 'coder']); ++ return { ...x, coder, cto, pm, toCto, toPm, toHuman, within }; ++} ++ ++test('roles lists open decisions routed to a role, and never the human inbox', (t) => { ++ const { b, human, reader, toCto, toPm, toHuman } = seed(t); ++ for (const cap of [human, reader]) { ++ const rows = list(b, cap, 'roles'); ++ assert.deepEqual( ++ rows.map((r) => [r.id, r.route_to]), ++ [ ++ [toCto.id, 'cto'], ++ [toPm.id, 'pm'], ++ ], ++ ); ++ // The same view as inbox: options parsed, blocking a boolean, the authorization context. ++ assert.deepEqual(rows[0].options, options); ++ assert.equal(rows[0].blocking, false); ++ assert.deepEqual(rows[0].authorization, { action: 'task.scope.change', target: 'vikunja:3/1', approvalChoice: 'yes' }); ++ assert.deepEqual( ++ call(b, cap, 'inbox').map((r) => r.id), ++ [toHuman.id], ++ ); ++ } ++}); ++ ++test('recent lists closed decisions of every route, newest close first, with how each closed', (t) => { ++ const { b, store, human, reader, coder, cto, toCto, toPm, toHuman, within } = seed(t); ++ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' }); ++ call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' }); ++ call(b, coder, 'decision.withdraw', { id: toPm.id }); ++ const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' }); ++ store.run( ++ "INSERT INTO decision_events(decision,at,op,by) VALUES(?,'2026-10-11T00:00:00.000Z','expired','broker')", ++ late.id, ++ ); ++ for (const cap of [human, reader]) { ++ const rows = list(b, cap, 'recent'); ++ assert.deepEqual( ++ rows.map((r) => [r.id, r.closed.op, r.closed.by, r.closed.choice, r.closed.via]), ++ [ ++ [late.id, 'expired', 'broker', null, null], ++ [toPm.id, 'withdrawn', 'demo-coder-run', null, 'broker'], ++ [toCto.id, 'resolved', 'demo-cto-run', 'yes', 'broker'], ++ [toHuman.id, 'resolved', 'jason', 'no', 'cli'], ++ [within.id, 'resolved', 'demo-coder-run', 'yes', 'broker'], ++ ], ++ ); ++ assert.equal(rows[3].closed.note, 'not today'); ++ assert.deepEqual(rows[3].options, options); ++ assert.equal(rows[3].blocking, true); ++ assert.deepEqual(list(b, cap, 'roles'), []); ++ } ++}); ++ ++test('recent stops at RECENT rows', (t) => { ++ const { b, reader, coder } = seed(t); ++ const ids = []; ++ for (let i = 0; i < RECENT; i++) ids.push(raise(b, coder, 'message.send', { choice: 'yes' }).id); ++ const rows = list(b, reader, 'recent'); ++ assert.equal(rows.length, RECENT); ++ assert.deepEqual( ++ rows.map((r) => r.id), ++ ids.reverse(), ++ ); ++}); ++ ++test('every row is what trail already gives the same session for that id', (t) => { ++ const { b, human, reader, toHuman } = seed(t); ++ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' }); ++ for (const cap of [human, reader]) ++ for (const row of [...list(b, cap, 'roles'), ...list(b, cap, 'recent')]) { ++ const trail = call(b, cap, 'trail', { subject: row.id }); ++ const d = trail.find((r) => r.table === 'decisions' && r.id === row.id); ++ assert.ok(d, row.id); ++ for (const k of ['route_to', 'class', 'action', 'question', 'recommendation', 'raised_by_role', 'raised_by_run']) ++ assert.equal(d[k], row[k], k); ++ assert.deepEqual(d.options, row.options); ++ if (row.closed) { ++ const { table, ...closed } = trail.find((r) => r.table === 'decision_events' && r.seq === row.closed.seq); ++ assert.equal(table, 'decision_events'); ++ assert.deepEqual(closed, { ...row.closed }); ++ } ++ } ++}); ++ ++test("a role session can't list decisions, so it can't read another role's open decisions", (t) => { ++ const { b, store, agent, coder, cto, pm, toCto, toPm, toHuman } = seed(t); ++ const idle = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-unclaimed', harness: 'pi', address: 'x' }); ++ const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n; ++ for (const cap of [cto, pm, coder, idle]) ++ for (const which of ['roles', 'recent', 'bogus']) { ++ const before = refusals(); ++ assert.throws(() => list(b, cap, which), /human-required/, which); ++ assert.equal(refusals(), before + 1, 'the refusal is recorded'); ++ } ++ // Its own inbox stays its own: cto sees cto's, not pm's or the human's. ++ assert.deepEqual( ++ call(b, cto, 'inbox').map((r) => r.id), ++ [toCto.id], ++ ); ++ assert.deepEqual( ++ call(b, pm, 'inbox').map((r) => r.id), ++ [toPm.id], ++ ); ++ assert.ok(!call(b, coder, 'inbox').some((r) => r.id === toHuman.id)); ++ // A role in another business reads none of demo's either. ++ assert.throws(() => list(b, agent('cto', 'other'), 'roles'), /human-required/); ++}); ++ ++test('decisions is a read: one business, strict arguments, nothing written', (t) => { ++ const { b, store, human, reader, agent } = seed(t); ++ const coder = agent('coder', 'other'); ++ raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:9/1' }); ++ raise(b, coder, 'message.send', { choice: 'yes' }); ++ const otherReader = b.bindReader({ business: 'other' }); ++ assert.equal(list(b, otherReader, 'roles').length, 1); ++ assert.equal(list(b, otherReader, 'recent').length, 1); ++ assert.ok(list(b, otherReader, 'roles').every((r) => r.business === 'other')); ++ assert.ok(list(b, reader, 'recent').every((r) => r.business === 'demo')); ++ ++ const count = () => ++ ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map( ++ (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n, ++ ); ++ const before = count(); ++ for (const cap of [human, reader]) { ++ list(b, cap, 'roles'); ++ list(b, cap, 'recent'); ++ } ++ assert.deepEqual(count(), before, 'a successful read writes nothing'); ++ for (const cap of [human, reader]) ++ for (const args of [{}, { list: 'all' }, { list: 'roles', limit: 5 }, { list: ['roles'] }, { list: 'Recent' }]) ++ assert.throws(() => call(b, cap, 'decisions', args), /invalid-request/, JSON.stringify(args)); ++ // The reader's other limits stand. ++ assert.throws(() => call(b, reader, 'decision.seen', { id: list(b, reader, 'roles')[0].id }), /read-only/); ++}); +diff --git a/packages/webui/README.md b/packages/webui/README.md +index 20a9a884..e43118d5 100644 +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -193,17 +193,22 @@ configured board URL for the page's error message. No scanner, registration, + session reader or transport is implemented here. The session reader is + `packages/conversation`, served by the board. + +-GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and +-`/api/bus/trail?subject=` are the bus reads (`src/bus.mjs`). Any other +-method is 405. The subject must match the broker's identifier rule, else 400. ++GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`, ++`/api/bus/trail?subject=` and `/api/bus/decisions?list=roles|recent` are ++the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match ++the broker's identifier rule, and the list must be `roles` or `recent`, else ++400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and ++"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each ++list holds. + Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as + a child process, so a slow read doesn't stall the server. The bus checks + that child's ancestry, which ends at the Console process; it never sees the + browser or any other client of the port. So while the Console runs, + anything that can connect to its port (7330 by default) reads what a reader + capability reads ("Human and reader paths" in `packages/bus/README.md`): +-the inbox, tasks, agents and trails. That includes a T3 seat using `curl` +-and a managed S6 session, since S6 doesn't confine the network. No route ++the inbox, tasks, agents, trails and the two decision lists. That includes ++a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine ++the network. No route + writes (Q4), so the exposure is reads only. Slice 1 doesn't change this + (Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }` + with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer +diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs +index 290541ef..8fee21a6 100644 +--- a/packages/webui/src/bus.mjs ++++ b/packages/webui/src/bus.mjs +@@ -1,8 +1,9 @@ + // Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human + // transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child + // with spawn, not spawnSync, so one slow read never stalls the HTTP server. +-// Only the four verbs of the Q1 module (lead decision 56) are reachable: +-// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a ++// Only the four verbs of the Q1 module (lead decision 56) and row 57's ++// `decisions` read are reachable: the WebUI writes nothing, not even seen ++// (Q4). The bus does the proof; a + // server started inside an agent run gets `human-required` from the broker. + + import { spawn } from 'node:child_process'; +diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs +index 8e89e3ed..4d51942a 100644 +--- a/packages/webui/src/serve.mjs ++++ b/packages/webui/src/serve.mjs +@@ -46,16 +46,22 @@ async function body(req) { + return bytes; + } + // Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4). +-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail']; ++// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. ++const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions']; ++const DECISION_LISTS = ['roles', 'recent']; + async function busRead(res, bus, verb, search) { +- let subject; ++ let arg; + if (verb === 'trail') { +- subject = new URLSearchParams(search).get('subject'); +- if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); ++ arg = new URLSearchParams(search).get('subject'); ++ if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); ++ } ++ if (verb === 'decisions') { ++ arg = new URLSearchParams(search).get('list'); ++ if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' }); + } + if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); + try { +- const rows = await bus[verb](subject); ++ const rows = await bus[verb](arg); + if (!Array.isArray(rows)) throw new BusReadError('invalid-response'); + return json(res, 200, { rows, at: new Date().toISOString() }); + } catch (err) { +diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs +index b141b5cc..2d9ca6ba 100644 +--- a/packages/webui/tests/bus.test.mjs ++++ b/packages/webui/tests/bus.test.mjs +@@ -87,13 +87,52 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t + assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']); + }); + ++// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists. ++test('decisions route serves the roles and recent lists from the reader view', async t => { ++ const store = new Store(tmp(t, 'webui-bus-decisions-')); ++ t.after(() => store.close()); ++ const b = new Broker({ store, businesses }); ++ const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' }); ++ b.request(coder, { verb: 'role.claim' }); ++ const raise = args => b.request(coder, { verb: 'decision.raise', args: { options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: false, ...args } }); ++ const routed = raise({ action: 'task.scope.change', domain: 'technical', target: 'vikunja:32/8', question: `Split it? ${HOSTILE}` }); ++ const human = raise({ action: 'deploy', target: 'release-1', question: 'Deploy?' }); ++ const closed = raise({ action: 'message.send', question: 'Send?', choice: 'yes' }); ++ const reader = b.bindReader({ business: 'demo' }); ++ const calls = []; ++ const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } }); ++ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus }); ++ t.after(() => close(web)); ++ const base = `http://127.0.0.1:${web.address().port}`; ++ const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; }; ++ ++ const roles = await read('/api/bus/decisions?list=roles'); ++ assert.equal(roles.status, 200); assert.equal(roles.cache, 'no-store'); ++ assert.match(roles.body.at, /^\d{4}-\d\d-\d\dT/); ++ assert.deepEqual(roles.body.rows.map(r => [r.id, r.route_to]), [[routed.id, 'cto']]); ++ assert.ok(roles.body.rows[0].question.endsWith(HOSTILE)); ++ const recent = await read('/api/bus/decisions?list=recent'); ++ assert.deepEqual(recent.body.rows.map(r => [r.id, r.closed.op, r.closed.choice]), [[closed.id, 'resolved', 'yes']]); ++ assert.deepEqual((await read('/api/bus/inbox')).body.rows.map(r => r.id), [human.id]); ++ assert.deepEqual(calls.filter(([v]) => v === 'decisions'), [['decisions', { list: 'roles' }], ['decisions', { list: 'recent' }]]); ++ ++ // A missing or unknown list never reaches the bus. ++ const before = calls.length; ++ for (const q of ['', '?list=', '?list=all', '?list=Roles', '?list=roles%00', '?subject=roles']) ++ assert.deepEqual([q, (await read('/api/bus/decisions' + q)).body.error], [q, 'invalid-request']); ++ assert.equal(calls.length, before); ++}); ++ + test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => { + const calls = []; +- const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }])); ++ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => { calls.push(v); return []; }])); + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus }); + t.after(() => close(web)); + const base = `http://127.0.0.1:${web.address().port}`; +- for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method); ++ for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) { ++ assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method); ++ assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method); ++ } + for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) { + assert.equal((await fetch(base + path)).status, 404, path); + assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path); +@@ -176,6 +215,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t + const dataRoot = join(root, 'data'); + const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli }); + assert.equal((await pinned.inbox()).request.business, 'pinned'); ++ assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } }); + + const reader = busReader({ dataRoot, socket: 'sock', cli }); + const code = () => reader.agents().then(() => 'resolved', e => e.code); diff --git a/agents/filbert/work/queue-57/candidate-manifest.sha256 b/agents/filbert/work/queue-57/candidate-manifest.sha256 new file mode 100644 index 000000000..eb6c5cc98 --- /dev/null +++ b/agents/filbert/work/queue-57/candidate-manifest.sha256 @@ -0,0 +1,8 @@ +690e9b583d229866f3f02cd23fc6b589a5444d92c19579e9fd631b9f41305e14 packages/bus/README.md +c5b7c6e5f74fd07b619693c6a85caca58ff049db76d6934e75ae22c3fa141053 packages/bus/src/broker.mjs +b7ae4ca62f98caa30fe1300811caef077e0b4758df1d0106fb1717d640c18760 packages/bus/src/views.mjs +052e8ec7e8c5903d6c689702ec0545094426093a2e36f5113ec16c9f9183d95e packages/bus/tests/decisions.test.mjs +df68ec0a53ead6b82636c2b86c86714306ecc3e848c3e3a44fd1e825b949f291 packages/webui/README.md +1b9d39dec208d1c987fe9b1fc921a61cdbbb2a2d2bcb3796a2bf422714be24b5 packages/webui/src/bus.mjs +d0d4be581cef0e25b7240457a757ed8bb41eaee21599e1f7b233ab2853f90601 packages/webui/src/serve.mjs +a449e0d9e69129ce3dabe479d842a7ac95d5311aad999dee96e46b1914c86140 packages/webui/tests/bus.test.mjs diff --git a/agents/filbert/work/queue-57/files.txt b/agents/filbert/work/queue-57/files.txt new file mode 100644 index 000000000..ba08b388f --- /dev/null +++ b/agents/filbert/work/queue-57/files.txt @@ -0,0 +1,8 @@ +packages/bus/README.md +packages/bus/src/broker.mjs +packages/bus/src/views.mjs +packages/bus/tests/decisions.test.mjs +packages/webui/README.md +packages/webui/src/bus.mjs +packages/webui/src/serve.mjs +packages/webui/tests/bus.test.mjs diff --git a/agents/filbert/work/queue-57/out/gate/node-bus.log b/agents/filbert/work/queue-57/out/gate/node-bus.log new file mode 100644 index 000000000..9ff5b97bd --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/node-bus.log @@ -0,0 +1,88 @@ +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (233.094409ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (373.024636ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (1078.360956ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (2805.481771ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (720.907418ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (602.634427ms) +✔ task action subjects and linked decision trail are complete and ordered (430.211921ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (169.171298ms) +✔ business isolation includes inherited object names and cross-business message references (352.708355ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (444.315056ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (209.464843ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (324.820708ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (333.421489ms) +✔ both arbiters require human resolution when their cross-role route is themselves (368.354325ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (3.445468ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (10.247728ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (4.89508ms) +✔ expiry refuses use and env references never become client data (11.201998ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.643114ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.549213ms) +✔ roles lists open decisions routed to a role, and never the human inbox (331.252577ms) +✔ recent lists closed decisions of every route, newest close first, with how each closed (439.195ms) +✔ recent stops at RECENT rows (5202.300816ms) +✔ every row is what trail already gives the same session for that id (449.42969ms) +✔ a role session can't list decisions, so it can't read another role's open decisions (658.481846ms) +✔ decisions is a read: one business, strict arguments, nothing written (568.907077ms) +✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (195.475135ms) +✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (216.16421ms) +✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (223.815301ms) +✔ endLaunch leaves a claim another run took alone (266.348358ms) +✔ refuse records action.refused against the caller with the code only (2304.731396ms) +✔ launches off refuses role.launch with launch-revoked until launches on (1833.471203ms) +✔ broker process: launch ops authorize role.launch, record refusals and end runs (784.153703ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (4.507649ms) +✔ process reader gets own kernel identity without exposing environment values (1.287823ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (3.315028ms) +✔ real pid 1 remains inspectable when its environment is protected (0.799191ms) +✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (319.822513ms) +✔ missing and foreign-business citations refuse and roll back message and grant (265.374166ms) +✔ cross-role sends still need a matching resolved decision and consume it once (964.86985ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (292.738892ms) +✔ startup token refusal returns safe code without value or partial listening broker (54.454834ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (263.557218ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (499.060254ms) +✔ trusted host registers later launches; socket clients never have a registration verb (2536.851756ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (356.20387ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (1117.970934ms) +✔ v3b prototype refusals, views and append-only mutations (5243.086738ms) +✔ gated approval authorizes once, survives store reopen, and fresh approval works (386.916491ms) +✔ another run cannot consume an approval; a failed check leaves it usable (337.214319ms) +✔ two scheduled callers have exactly one grant and one consumed refusal (751.934799ms) +✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (3616.852713ms) +✔ class drift gated to cross-role refuses before consumption (638.164117ms) +✔ class drift cross-role to gated refuses before consumption (524.076534ms) +✔ class drift gated to within-role refuses before consumption (384.467028ms) +✔ class drift cross-role to within-role refuses before consumption (380.410298ms) +✔ class drift within-role to gated refuses before consumption (320.484502ms) +✔ class drift within-role to cross-role refuses before consumption (301.809385ms) +✔ message.send consumes approval and prevents a later send or authorize (465.422414ms) +✔ role.revoke consumes approval and prevents a later revoke or authorize (385.859952ms) +✔ creates private WAL store and excludes a second writer until explicit close (197.610501ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (248.321479ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (314.800877ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (2159.568549ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (1346.65285ms) +✔ async transactions refuse before invoking their function (711.543159ms) +✔ recordTask keeps sync reads and a role write apart (265.085426ms) +✔ read_at must be one canonical UTC format, so the projection compares strings safely (148.434209ms) +✔ a bad entry refuses the whole record (160.806142ms) +✔ taskView reads the projection for one business (209.017213ms) +✔ requestTask hands only a holder and a task verb to the handler, and records refusals (3479.055075ms) +✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (1176.287241ms) +✔ without an adapter the server refuses every task verb (658.484711ms) +✔ the runtime refuses an invalid adapter and closes a valid one (422.552128ms) +✔ the process loads the S3 adapter from plain-data trackers (531.329288ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (218.007537ms) +✔ two wire claims serialize; a lost reply never automatically retries (243.872356ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (181.884844ms) +✔ client preserves UTF-8 when a response divides a multibyte character (13.706377ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (548.0716ms) +ℹ tests 80 +ℹ suites 0 +ℹ pass 80 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 8653.757066 diff --git a/agents/filbert/work/queue-57/out/gate/node-webui.log b/agents/filbert/work/queue-57/out/gate/node-webui.log new file mode 100644 index 000000000..59b9b9e83 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/node-webui.log @@ -0,0 +1,49 @@ +✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (10969.834809ms) +Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286} +✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (26960.907129ms) +✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (22692.244104ms) +✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (540.466922ms) +✔ decisions route serves the roles and recent lists from the reader view (495.652478ms) +✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (264.780998ms) +✔ bus refusals map to statuses and never carry a path or a stack (64.517399ms) +✔ humanCall speaks the human transport protocol without blocking the server (2475.443577ms) +✔ busReader takes --business, else the live bus host, else refuses (521.362418ms) +✔ humanCall kills a transport that runs past its timeout (558.840546ms) +✔ serve refuses a --business value that is not a business id (305.329249ms) +✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (18560.243478ms) +✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5658.200446ms) +✔ conversation view: a newer session with no readable history keeps the marker (5085.577477ms) +✔ conversation view: seats without history say so and offer no reply (5597.344603ms) +✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (10572.359237ms) +✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (64391.819731ms) +✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (3.267785ms) +✔ credential state from the date alone, as the bus states it (0.477653ms) +✔ queue: rows, one row, ids and methods, notes; no path or id from a row (2293.473775ms) +✔ queue: a refused read fails closed with the store's text, redacted (2068.310913ms) +✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (5717.973112ms) +✔ business: names, vars on the allowlist, authority, credential metadata only (48.950144ms) +✔ business: missing or invalid file refuses with the module's text, redacted (74.880382ms) +✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (23.654078ms) +✔ settings: release, board, loopback address, notifier binding name only (1413.526676ms) +✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (11967.031582ms) +✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (32919.117251ms) +✔ loopback host and board origin fail closed (15.068546ms) +✔ real board fixture passes through WebUI; assets and isolated seen/reply work (162.240605ms) +✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (70.022971ms) +✔ unreachable board reports URL; CLI rejects unsupported options (1578.434888ms) +✔ tokens.css and icons.svg are byte copies of docs/design (7.201918ms) +✔ JetBrains Mono files match the digests in their sources file, with the OFL text (7.017662ms) +✔ shipped strings are unchanged, and the web keeps its write boundary (2.457525ms) +✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (18840.161593ms) +✔ five states on Board, Inbox, Tasks, Agents and Trail (7644.99647ms) +✔ Queue, Business and Settings in their five states, with no move button (14380.510749ms) +✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (7408.726728ms) +✔ the server log names no config path when the system config refuses (322.369609ms) +ℹ tests 40 +ℹ suites 0 +ℹ pass 40 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 64983.754866 diff --git a/agents/filbert/work/queue-57/out/gate/summary.txt b/agents/filbert/work/queue-57/out/gate/summary.txt new file mode 100644 index 000000000..f500a6ae3 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/summary.txt @@ -0,0 +1,12 @@ +node-bus exit 0 ℹ pass 80 ℹ fail 0 +node-webui exit 0 ℹ pass 40 ℹ fail 0 +test-auth exit 0 selftest: 15 passed, 0 failed +test-conductor exit 0 selftest: 17 passed, 0 failed +test-config exit 0 selftest: 24 passed, 0 failed +test-discord exit 0 discord suite: 66 passed, 0 failed +test-extension-package exit 0 extension package selftest: 18 passed, 0 failed +test-foundation exit 0 selftest: 44 passed, 0 failed +test-queue exit 0 queue suite: 27 passed, 0 failed +test-release exit 0 selftest: 4 passed, 0 failed +test-task exit 0 selftest: 26 passed, 0 failed +done diff --git a/agents/filbert/work/queue-57/out/gate/test-auth.log b/agents/filbert/work/queue-57/out/gate/test-auth.log new file mode 100644 index 000000000..ac2287606 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-auth.log @@ -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 diff --git a/agents/filbert/work/queue-57/out/gate/test-conductor.log b/agents/filbert/work/queue-57/out/gate/test-conductor.log new file mode 100644 index 000000000..62afc513c --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-conductor.log @@ -0,0 +1,23 @@ +On branch refactor +Your branch is up to date with 'origin/refactor'. + +nothing to commit, working tree clean +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 diff --git a/agents/filbert/work/queue-57/out/gate/test-config.log b/agents/filbert/work/queue-57/out/gate/test-config.log new file mode 100644 index 000000000..76c05ef74 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-config.log @@ -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 diff --git a/agents/filbert/work/queue-57/out/gate/test-discord.log b/agents/filbert/work/queue-57/out/gate/test-discord.log new file mode 100644 index 000000000..b013f9ffa --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-discord.log @@ -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 diff --git a/agents/filbert/work/queue-57/out/gate/test-extension-package.log b/agents/filbert/work/queue-57/out/gate/test-extension-package.log new file mode 100644 index 000000000..2f0037cea --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-extension-package.log @@ -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: 2588080 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 diff --git a/agents/filbert/work/queue-57/out/gate/test-foundation.log b/agents/filbert/work/queue-57/out/gate/test-foundation.log new file mode 100644 index 000000000..6338e1e6d --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-foundation.log @@ -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 diff --git a/agents/filbert/work/queue-57/out/gate/test-queue.log b/agents/filbert/work/queue-57/out/gate/test-queue.log new file mode 100644 index 000000000..0c39f0443 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-queue.log @@ -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 149 +ℹ pass 149 +ℹ fail 0 +OK node --test packages/queue/tests/ +OK scripts/mosaic queue help +skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r57) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + +queue suite: 27 passed, 0 failed diff --git a/agents/filbert/work/queue-57/out/gate/test-release.log b/agents/filbert/work/queue-57/out/gate/test-release.log new file mode 100644 index 000000000..1d6ed0503 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-release.log @@ -0,0 +1,7 @@ +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 +skip state-machine cases (docker daemon unavailable) + +selftest: 4 passed, 0 failed diff --git a/agents/filbert/work/queue-57/out/gate/test-task.log b/agents/filbert/work/queue-57/out/gate/test-task.log new file mode 100644 index 000000000..cd89fe9b2 --- /dev/null +++ b/agents/filbert/work/queue-57/out/gate/test-task.log @@ -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 diff --git a/agents/filbert/work/queue-57/out/mutants/result-rerun.txt b/agents/filbert/work/queue-57/out/mutants/result-rerun.txt new file mode 100644 index 000000000..157729f3c --- /dev/null +++ b/agents/filbert/work/queue-57/out/mutants/result-rerun.txt @@ -0,0 +1,3 @@ +roles-with-human: bus rc=1 fail=1 roles lists open decisions routed to a role, and never the human inbox ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view +roles-other-business: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0 +done diff --git a/agents/filbert/work/queue-57/out/mutants/result.txt b/agents/filbert/work/queue-57/out/mutants/result.txt new file mode 100644 index 000000000..7f6eb704c --- /dev/null +++ b/agents/filbert/work/queue-57/out/mutants/result.txt @@ -0,0 +1,18 @@ +role-allowed: bus rc=1 fail=1 a role session can't list decisions, so it can't read another role's open decisions ; webui rc=0 fail=0 +roles-with-human: bus rc=1 fail=1 roles lists open decisions routed to a role, and never the human inbox ; webui rc=1 fail=undefined decisions route serves the roles and recent lists from the reader view | conversation view: a newer session with no readable history keeps the marker | return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch | reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh +roles-with-closed: bus rc=1 fail=3 roles lists open decisions routed to a role, and never the human inbox | recent lists closed decisions of every route, newest close first, with how each closed | decisions is a read: one business, strict arguments, nothing written ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view +roles-other-business: PATTERN NOT UNIQUE +recent-other-business: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0 +recent-oldest-first: bus rc=1 fail=2 recent lists closed decisions of every route, newest close first, with how each closed | recent stops at RECENT rows ; webui rc=0 fail=0 +recent-by-raise: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=0 fail=0 +recent-no-limit: bus rc=1 fail=1 recent stops at RECENT rows ; webui rc=0 fail=0 +recent-limit-plus-one: bus rc=1 fail=1 recent stops at RECENT rows ; webui rc=0 fail=0 +recent-resolved-only: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=0 fail=0 +recent-no-closed: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view +loose-keys: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0 +unknown-list-empty: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0 +reader-refused: bus rc=1 fail=5 roles lists open decisions routed to a role, and never the human inbox | recent lists closed decisions of every route, newest close first, with how each closed | recent stops at RECENT rows | every row is what trail already gives the same session for that id ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view +web-no-list-check: bus rc=0 fail=0 ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view +web-no-verb: bus rc=0 fail=0 ; webui rc=1 fail=2 decisions route serves the roles and recent lists from the reader view | bus routes are GET-only, have no write verb, and keep the same-origin checks +views-fixed-list: bus rc=0 fail=0 ; webui rc=1 fail=2 decisions route serves the roles and recent lists from the reader view | busReader takes --business, else the live bus host, else refuses +done diff --git a/agents/filbert/work/queue-57/out/mutants/run.mjs b/agents/filbert/work/queue-57/out/mutants/run.mjs new file mode 100644 index 000000000..9acf5eef0 --- /dev/null +++ b/agents/filbert/work/queue-57/out/mutants/run.mjs @@ -0,0 +1,64 @@ +// Filbert's row 57 mutants: each edits one file in the mutant worktree, runs +// the bus and webui suites, and restores the file. Usage: node run.mjs +import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +const W = process.argv[2]; +const OUT = new URL('.', import.meta.url).pathname; +const B = 'packages/bus/src/broker.mjs'; +const S = 'packages/webui/src/serve.mjs'; +const V = 'packages/bus/src/views.mjs'; +const mutants = [ + ['role-allowed', B, "if (!s.human && !s.reader) fail('human-required');", ''], + ['roles-with-human', B, "AND route_to<>'human' AND", 'AND'], + ['roles-with-closed', B, ` AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN \${CLOSED}) ORDER BY seq`, ' ORDER BY seq'], + ['roles-other-business', B, "WHERE business=? AND route_to<>'human'", "WHERE ? IS NOT NULL AND route_to<>'human'"], + ['recent-other-business', B, 'WHERE d.business=? ORDER BY x.seq DESC', 'WHERE ? IS NOT NULL ORDER BY x.seq DESC'], + ['recent-oldest-first', B, 'ORDER BY x.seq DESC LIMIT', 'ORDER BY x.seq ASC LIMIT'], + ['recent-by-raise', B, 'ORDER BY x.seq DESC LIMIT', 'ORDER BY d.seq DESC LIMIT'], + ['recent-no-limit', B, ' LIMIT ${RECENT}`', '`'], + ['recent-limit-plus-one', B, ' LIMIT ${RECENT}`', ' LIMIT ${RECENT + 1}`'], + ['recent-resolved-only', B, `JOIN decision_events x ON x.decision=d.id AND x.op IN \${CLOSED}`, `JOIN decision_events x ON x.decision=d.id AND x.op IN ('resolved')`], + ['recent-no-closed', B, '({ ...this.#decisionView(d), closed: this.#closed(d.id) })', '({ ...this.#decisionView(d) })'], + ['loose-keys', B, "keys(a, ['list'], ['list']);\n if (a.list === 'roles')", "keys(a, ['list', 'limit'], ['list']);\n if (a.list === 'roles')"], + ['unknown-list-empty', B, " .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));\n fail('invalid-request');", " .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));\n return [];"], + ['reader-refused', B, "'trail', 'decisions'].includes(request.verb))", "'trail'].includes(request.verb))"], + ['web-no-list-check', S, "if (!DECISION_LISTS.includes(arg)) return", 'if (false) return'], + ['web-no-verb', S, "const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];", "const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];"], + ['views-fixed-list', V, "decisions: (list) => client.call('decisions', { list }),", "decisions: () => client.call('decisions', { list: 'roles' }),"], +]; + +const suites = [ + ['bus', ['--test', 'packages/bus/tests/*.test.mjs'], W], + ['webui', ['--test', 'tests/'], join(W, 'packages/webui')], +]; +const env = { ...process.env, TMPDIR: process.env.HOME + '/filbert-scratch/tmp', DOCKER_HOST: 'unix:///nonexistent.sock' }; +const log = join(OUT, process.argv[4] || 'result.txt'); +writeFileSync(log, ''); +const only = process.argv[3] ? process.argv[3].split(',') : null; +for (const [name, file, from, to] of mutants) { + if (only && !only.includes(name)) continue; + const path = join(W, file); + const orig = readFileSync(path, 'utf8'); + if (orig.split(from).length !== 2) { + appendFileSync(log, `${name}: PATTERN NOT UNIQUE\n`); + continue; + } + writeFileSync(path, orig.replace(from, to)); + const parts = []; + try { + for (const [label, args, cwd] of suites) { + const r = spawnSync('node', args, { cwd, env, encoding: 'utf8', timeout: 600000 }); + const out = r.stdout + r.stderr; + writeFileSync(join(OUT, `${name}.${label}.log`), out); + const fail = (out.match(/^ℹ fail (\d+)/m) || [])[1]; + const fails = [...out.matchAll(/^\s*✖ (.+?) \(\d/gm)].map((m) => m[1]).filter((t) => !/^tests?\//.test(t)); + parts.push(`${label} rc=${r.status} fail=${fail} ${[...new Set(fails)].slice(0, 4).join(' | ')}`); + } + } finally { + writeFileSync(path, orig); + } + appendFileSync(log, `${name}: ${parts.join(' ; ')}\n`); +} +appendFileSync(log, 'done\n');