docs(queue-57): row 57 candidate packet, decisions read for roles and recent (filbert)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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(<decision id>)` 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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
958668a8476250edab8025a48a5c553dfd75f99b
|
||||||
@@ -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=<id>` 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=<id>` 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);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,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
|
||||||
@@ -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: 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
|
||||||
@@ -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 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
|
||||||
@@ -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
|
||||||
@@ -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,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
|
||||||
@@ -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
|
||||||
@@ -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 <worktree>
|
||||||
|
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');
|
||||||
Reference in New Issue
Block a user