feat(tasks): bind requirement and git admission to product scope (#1839)

This commit is contained in:
jason.woltje committed 2026-11-04 11:24:39 -06:00
1 parent 37d44f36e0
commit b5a58b48b4
21 files changed
+640 -95

No files matched your search

+39
View File
@@ -6019,3 +6019,42 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-04T17:07:37Z | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Before: c098fa022 rejects the bare --proxy args line; read_old and stop/recovery cover only bus and Console, so a proxy hub cannot upgrade or roll back. After: accept only the bare flag, require all selected installed units to name one copy, refuse missing/linked/malformed/disagreeing proxy units and an installed proxy without its flag before stopping anything; stop Console, bus, proxy, wait for each, and preserve --proxy through both new and old renders. A2f starts proxy first and refuses a failed proxy restart with all three stopped; all rollback-failed cleanup includes the proxy. Header exit-code meanings and the ordinary two-unit path remain unchanged. Section 7 documents the flag and lifecycle. Nine added tests cover upgrade, explicit rollback, failed-upgrade restoration of all three unit files, A2f recovery/start failure, final stopped cleanup, no-proxy compatibility, malformed args, installed-unit refusals, and the real factory renderer re-pin/rollback. RED: focused #772 3 pass/5 fail/0 skip on the base, all five failures at args-unreadable --proxy. Corrections: first GREEN candidate 7/1/0 exposed the render stand-in using a different checkout path; a --checkout override was unsupported (6/3/0), so the stand-in now runs from the same fixture clone as setup; final focused 9/0/0. Required final gates (pass/fail/skip): node --test packages/cli/tests/stack-upgrade.test.mjs 34/0/0; node --test packages/cli/tests/case1-accept.test.mjs 53/0/0; node --test packages/cli/tests/case2-install.test.mjs 28/0/0; node --test packages/cli/tests/case2-unit.test.mjs 88/0/0; bash scripts/test-case2.sh 21/0/0. The four matching test files were found with git grep. Initial case2-install 6/22/0 lacked node_modules/yaml; initial scratch acceptance 13/8/0 lacked the installed dependency tree and passed after lockfile-only npm ci --ignore-scripts with distinct empty user/global configs and a scratch cache. The first npm setup attempt refused duplicate /dev/null config paths before installation. Every test invocation set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX. bash -n and git diff --check passed. Logs: /mnt/storage/scratch/tmp/JBjOT3/ (RED/intermediate/focused and all required gate logs, npm setup, verified-source.sha256). Queue next refused the linked worktree; canonical queue movement remains with the coordinator. Fixture systemctl/sudo and synthetic configs only; no live host/unit, real credentials, ~/.config/mosaic-*, ~/.mosaic or /srv/mosaic access. One local commit authorized; no push, merge or deployment. No remaining code limitation; live .53 acceptance is outside this assignment. 2026-11-04T17:07:37Z | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Before: c098fa022 rejects the bare --proxy args line; read_old and stop/recovery cover only bus and Console, so a proxy hub cannot upgrade or roll back. After: accept only the bare flag, require all selected installed units to name one copy, refuse missing/linked/malformed/disagreeing proxy units and an installed proxy without its flag before stopping anything; stop Console, bus, proxy, wait for each, and preserve --proxy through both new and old renders. A2f starts proxy first and refuses a failed proxy restart with all three stopped; all rollback-failed cleanup includes the proxy. Header exit-code meanings and the ordinary two-unit path remain unchanged. Section 7 documents the flag and lifecycle. Nine added tests cover upgrade, explicit rollback, failed-upgrade restoration of all three unit files, A2f recovery/start failure, final stopped cleanup, no-proxy compatibility, malformed args, installed-unit refusals, and the real factory renderer re-pin/rollback. RED: focused #772 3 pass/5 fail/0 skip on the base, all five failures at args-unreadable --proxy. Corrections: first GREEN candidate 7/1/0 exposed the render stand-in using a different checkout path; a --checkout override was unsupported (6/3/0), so the stand-in now runs from the same fixture clone as setup; final focused 9/0/0. Required final gates (pass/fail/skip): node --test packages/cli/tests/stack-upgrade.test.mjs 34/0/0; node --test packages/cli/tests/case1-accept.test.mjs 53/0/0; node --test packages/cli/tests/case2-install.test.mjs 28/0/0; node --test packages/cli/tests/case2-unit.test.mjs 88/0/0; bash scripts/test-case2.sh 21/0/0. The four matching test files were found with git grep. Initial case2-install 6/22/0 lacked node_modules/yaml; initial scratch acceptance 13/8/0 lacked the installed dependency tree and passed after lockfile-only npm ci --ignore-scripts with distinct empty user/global configs and a scratch cache. The first npm setup attempt refused duplicate /dev/null config paths before installation. Every test invocation set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX. bash -n and git diff --check passed. Logs: /mnt/storage/scratch/tmp/JBjOT3/ (RED/intermediate/focused and all required gate logs, npm setup, verified-source.sha256). Queue next refused the linked worktree; canonical queue movement remains with the coordinator. Fixture systemctl/sudo and synthetic configs only; no live host/unit, real credentials, ~/.config/mosaic-*, ~/.mosaic or /srv/mosaic access. One local commit authorized; no push, merge or deployment. No remaining code limitation; live .53 acceptance is outside this assignment.
2026-11-04T04:05Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers, on 6dd4f92b0. The owner now picks the mode (new autonomous, new guided, existing project), answers the PM's questions and confirms an attachment with buttons; no copy-to-terminal step. BUS: 3p->3q append-only migration widens the request_events kind CHECK (bootstrap.completed, question.asked, question.answered, attach.selected, mode.changed, cancelled) and keeps rows, seq and guards; reduce gains question, selection-attach, mode and cancel state; PM-only request.question; owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console (the cancel verb is beyond the brief's list, needed for its Cancel rule) join request.get.console and request.projects.console in CONSOLE_VERBS (8 verbs, #human CLI guard untouched, agent intake verbs keep their names, ROUTED and roles/*.json untouched). Every write replays a same-op same-digest call as the current state, a different digest is request-operation-conflict, then revision, phase and question checks. A guided answer is an attributed human.input linked to question and request, once per op, secret-scanned, never a PRD approval and never a rewrite of the spec; the PM continuation is enqueued after commit. Attach records the pick from the authorized picker and the PM proposes exactly that project as a confirmation decision (a propose that differs is request-attachment-mismatch); nothing is mapped on the pick. A mode change takes the expected revision and mode_revision, bumps mode_revision and leaves the bound plan as evidence, so a pending autonomous approval or dispatch admission bound to the old mode_revision no longer matches at the next action. Cancel withdraws an open selection decision (pre-task decisions are blocking:false) or appends cancellation and keeps a created project and its mapping as evidence. thread.messages adds the request id to human instruction rows for the owner only. WEBUI: src/request-http.mjs routes GET /api/requests/:request, /projects, /draft and POST /answer|/attach|/mode|/cancel through consoleCall as the signed-in (provider, iss, sub); writes need the signed-in owner, a single same-origin Origin, Sec-Fetch-Site same-origin, application/json, an exact key set, a bounded body (4 KB, answers 64 KB / 8000 characters) and a live() recheck before the spawn; the draft read answers draft-unavailable (503) until Task 4 adds request.draft.console. serve.mjs and cli.mjs take requestRead/requestWrite (sso and send required, refused on the tester listener) and /api/config reports requests and requestWrites to the owner only. Page: public/requests.js mounts under the owner's latest three chat messages that opened a request (chat.js emits the mounts and calls MosaicRequests.mount after each paint), renders every bus string escaped with control and bidi characters shown as \uXXXX, answers the mode decision through the existing /api/decisions/:id/confirmation and /resolve pair, stores an answer in sessionStorage before sending it, and after a lost response reads the request first and resends the same op only on the owner's click. Nothing is retried by the page. Sign-out clears the panels and stored answers. RED->GREEN: packages/bus/tests/request-console.test.mjs (15), packages/webui/tests/request-http.test.mjs (14), packages/webui/tests/request-browser.test.mjs (12, real Chromium; browser.test.mjs passes 1/1 so the host browser limit did not apply). Existing pins updated as required consequences: 3q schema pin in 13 bus test files, console-decide N4 verb list (2 -> 8), browser.test.mjs script list (+/requests.js). GREEN: webui npm test 538/538, bus npm test 1492/1492 (the first full run had one failure, model-final-completion 656 FIFO startNice 16 !== 19, the same niceness flake recorded for Task 2; the file passed 21/21 alone and the full rerun was green), request-intake plus request-bootstrap 70/70, zero skips. Limits: until Task 6 the PM continuation after an answer is a plain INFO message; the draft PRD view has no panel until Task 4; the owner's attach pick is lost if the PM later appends a different setup wait; mode change is refused during bootstrap; a cancel queues behind an in-flight bootstrap; an open non-selection decision on a cancelled request is not withdrawn; a session being cleared or retired makes the answer fail closed; request_project_events kinds remain unwritten; 3q may collide with the parallel #802 migration number. Logs /mnt/storage/scratch/tmp/0pihwW/. Not executed on any running instance or Console; local commit only, no push/merge/PR/deployment. 2026-11-04T04:05Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers, on 6dd4f92b0. The owner now picks the mode (new autonomous, new guided, existing project), answers the PM's questions and confirms an attachment with buttons; no copy-to-terminal step. BUS: 3p->3q append-only migration widens the request_events kind CHECK (bootstrap.completed, question.asked, question.answered, attach.selected, mode.changed, cancelled) and keeps rows, seq and guards; reduce gains question, selection-attach, mode and cancel state; PM-only request.question; owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console (the cancel verb is beyond the brief's list, needed for its Cancel rule) join request.get.console and request.projects.console in CONSOLE_VERBS (8 verbs, #human CLI guard untouched, agent intake verbs keep their names, ROUTED and roles/*.json untouched). Every write replays a same-op same-digest call as the current state, a different digest is request-operation-conflict, then revision, phase and question checks. A guided answer is an attributed human.input linked to question and request, once per op, secret-scanned, never a PRD approval and never a rewrite of the spec; the PM continuation is enqueued after commit. Attach records the pick from the authorized picker and the PM proposes exactly that project as a confirmation decision (a propose that differs is request-attachment-mismatch); nothing is mapped on the pick. A mode change takes the expected revision and mode_revision, bumps mode_revision and leaves the bound plan as evidence, so a pending autonomous approval or dispatch admission bound to the old mode_revision no longer matches at the next action. Cancel withdraws an open selection decision (pre-task decisions are blocking:false) or appends cancellation and keeps a created project and its mapping as evidence. thread.messages adds the request id to human instruction rows for the owner only. WEBUI: src/request-http.mjs routes GET /api/requests/:request, /projects, /draft and POST /answer|/attach|/mode|/cancel through consoleCall as the signed-in (provider, iss, sub); writes need the signed-in owner, a single same-origin Origin, Sec-Fetch-Site same-origin, application/json, an exact key set, a bounded body (4 KB, answers 64 KB / 8000 characters) and a live() recheck before the spawn; the draft read answers draft-unavailable (503) until Task 4 adds request.draft.console. serve.mjs and cli.mjs take requestRead/requestWrite (sso and send required, refused on the tester listener) and /api/config reports requests and requestWrites to the owner only. Page: public/requests.js mounts under the owner's latest three chat messages that opened a request (chat.js emits the mounts and calls MosaicRequests.mount after each paint), renders every bus string escaped with control and bidi characters shown as \uXXXX, answers the mode decision through the existing /api/decisions/:id/confirmation and /resolve pair, stores an answer in sessionStorage before sending it, and after a lost response reads the request first and resends the same op only on the owner's click. Nothing is retried by the page. Sign-out clears the panels and stored answers. RED->GREEN: packages/bus/tests/request-console.test.mjs (15), packages/webui/tests/request-http.test.mjs (14), packages/webui/tests/request-browser.test.mjs (12, real Chromium; browser.test.mjs passes 1/1 so the host browser limit did not apply). Existing pins updated as required consequences: 3q schema pin in 13 bus test files, console-decide N4 verb list (2 -> 8), browser.test.mjs script list (+/requests.js). GREEN: webui npm test 538/538, bus npm test 1492/1492 (the first full run had one failure, model-final-completion 656 FIFO startNice 16 !== 19, the same niceness flake recorded for Task 2; the file passed 21/21 alone and the full rerun was green), request-intake plus request-bootstrap 70/70, zero skips. Limits: until Task 6 the PM continuation after an answer is a plain INFO message; the draft PRD view has no panel until Task 4; the owner's attach pick is lost if the PM later appends a different setup wait; mode change is refused during bootstrap; a cancel queues behind an in-flight bootstrap; an open non-selection decision on a cancelled request is not withdrawn; a session being cleared or retired makes the answer fail closed; request_project_events kinds remain unwritten; 3q may collide with the parallel #802 migration number. Logs /mnt/storage/scratch/tmp/0pihwW/. Not executed on any running instance or Console; local commit only, no push/merge/PR/deployment.
2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Before: Task 3 sat on 6dd4f92b0 with its own 3p->3q migration and its own request-level request.mode.console; origin/refactor had moved to c098fa022, whose Task 4 core already shipped 3p-3q.sql (prd.submitted, prd.activated, prd.invalidated, mode.changed) and a project-level modeConsole. After: the two reviewed commits (7fd0c3562, c52218c65, still reachable) are replayed on c098fa022 as e9a12beb1 and e6837f76a, plus one webui reconciliation commit. MIGRATION: Task 4's 3p-3q.sql is untouched and byte-identical to c098fa022. Task 3's step is 3q-3r.sql and SCHEMA_VERSION is 3r. 3q-3r rebuilds request_events with the 15-kind union (opened, selection.proposed, selection.staged, selection.confirmed, setup.wait, bootstrap.stale, bootstrap.completed, prd.submitted, prd.activated, prd.invalidated, question.asked, question.answered, attach.selected, mode.changed, cancelled), so every kind Task 4 added is kept and the three Task 3 kinds are added; rows, seq, sqlite_sequence, the index and the six guard triggers carry over. schema.sql is the same union and SCHEMA_SHA256 was recomputed (4dfc55b4...2092). The 3b..3r chain replays to the fresh schema digest (comment-migration, schema and request-console migration tests; migrate-schema prints bus schema 3r ready). MODE: Task 3's request-level mode path (reducer branch, lifecycle changeMode, broker recordMode, MODES and MODE_PHASES, its two request-console tests and the C1 mode edge test) is removed. request.mode.console is Task 4's modeConsole: it moves the project's mode revision and every attached request, returns each to discovery with wait request-mode-changed, and refuses a stale mode revision as request-mode-revision-conflict. It sends no human.input or PM message, so it has no messaging edge to check; C1 still covers answer, attach and cancel. WEBUI: the mode route keeps its wire shape; request-mode-stale and request-mode-unchanged (codes no bus emits) became request-mode-revision-conflict (409), the mode copy and the request-mode-changed wait label describe Task 4's behaviour, and the draft route now relays Task 4's { candidate, prd, requirements } reply (the candidate must name the request) with prd-candidate-required as 409. CORRECTIONS to the 04:05Z entry above: it says 3p->3q, 8 console verbs and a request.mode.console that bumps mode_revision without leaving discovery; on this base the migration is 3q->3r, CONSOLE_VERBS has 10 entries and mode is Task 4's. TESTS: bus 1536/1536, webui 540/540 (one earlier whole-directory run failed decide-sso on a browser EPIPE and passed alone and on rerun), targeted request-console, request-intake and request-bootstrap 108/108, request-http and request-browser 28/28, prd-activation 21/21, project prd 6/6; zero skips. Not pushed. 2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Before: Task 3 sat on 6dd4f92b0 with its own 3p->3q migration and its own request-level request.mode.console; origin/refactor had moved to c098fa022, whose Task 4 core already shipped 3p-3q.sql (prd.submitted, prd.activated, prd.invalidated, mode.changed) and a project-level modeConsole. After: the two reviewed commits (7fd0c3562, c52218c65, still reachable) are replayed on c098fa022 as e9a12beb1 and e6837f76a, plus one webui reconciliation commit. MIGRATION: Task 4's 3p-3q.sql is untouched and byte-identical to c098fa022. Task 3's step is 3q-3r.sql and SCHEMA_VERSION is 3r. 3q-3r rebuilds request_events with the 15-kind union (opened, selection.proposed, selection.staged, selection.confirmed, setup.wait, bootstrap.stale, bootstrap.completed, prd.submitted, prd.activated, prd.invalidated, question.asked, question.answered, attach.selected, mode.changed, cancelled), so every kind Task 4 added is kept and the three Task 3 kinds are added; rows, seq, sqlite_sequence, the index and the six guard triggers carry over. schema.sql is the same union and SCHEMA_SHA256 was recomputed (4dfc55b4...2092). The 3b..3r chain replays to the fresh schema digest (comment-migration, schema and request-console migration tests; migrate-schema prints bus schema 3r ready). MODE: Task 3's request-level mode path (reducer branch, lifecycle changeMode, broker recordMode, MODES and MODE_PHASES, its two request-console tests and the C1 mode edge test) is removed. request.mode.console is Task 4's modeConsole: it moves the project's mode revision and every attached request, returns each to discovery with wait request-mode-changed, and refuses a stale mode revision as request-mode-revision-conflict. It sends no human.input or PM message, so it has no messaging edge to check; C1 still covers answer, attach and cancel. WEBUI: the mode route keeps its wire shape; request-mode-stale and request-mode-unchanged (codes no bus emits) became request-mode-revision-conflict (409), the mode copy and the request-mode-changed wait label describe Task 4's behaviour, and the draft route now relays Task 4's { candidate, prd, requirements } reply (the candidate must name the request) with prd-candidate-required as 409. CORRECTIONS to the 04:05Z entry above: it says 3p->3q, 8 console verbs and a request.mode.console that bumps mode_revision without leaving discovery; on this base the migration is 3q->3r, CONSOLE_VERBS has 10 entries and mode is Task 4's. TESTS: bus 1536/1536, webui 540/540 (one earlier whole-directory run failed decide-sso on a browser EPIPE and passed alone and on rerun), targeted request-console, request-intake and request-bootstrap 108/108, request-http and request-browser 28/28, prd-activation 21/21, project prd 6/6; zero skips. Not pushed.
### 2026-11-04 — Lifecycle Task 5b: scoped requirements and Git admission (#797, #1839)
Before: Task 5a create recovery was already on `origin/refactor` at `103168614`.
Task 4 (`76207fe48`, still under review) was cherry-picked as
`1627e24d79da00a5a6c393fd0b15cea4362bdb1d`; both append-only log sides were kept.
Task creation still used a private Stack requirement ID set, and Git admission did
not bind a task to its product. The queue command refused this linked worktree;
this session followed the owner's explicit Task 5b implementation assignment.
After: broker discovery and host create admission use Task 4's catalogue, with
checks during planning and immediately before admission. Product creates require
request, requirement, project key, exact approved snapshot and stable op, resolve
the board from the confirmed request, and refresh tracker membership/installation
and role/session authority. Creation evidence retains TaskScope, PRD version and
records/spec commits, including recovered creates under original attribution.
Filters/trails distinguish colliding requirement IDs. Explicit host startup binds
the baked catalogue only to the committed Stack identity with foundation ancestry
and exact repository PRD/catalogue bytes; derived products cannot borrow it. Legacy
unscoped discovery keeps its response, while intake never falls back. Host injection
preserves Broker's sandbox import closure. Git socket/process/runtime pass the code
project before bundle bytes; push recording, review and export recheck the binding.
PM tool schemas carry the scope fields. No migration; scope uses existing JSON
evidence. `requirements.json` and its consistency test are unchanged.
Verification: new scope/recovery/Git tests **35/35**; required compatibility command
**107/107**; `npm test` in tasks **132/132**, in bus **1501/1501**; additional CLI
configuration/PM tool tests **32/32**. Every final run has zero skips, failures or
cancellations, using a fresh `TMPDIR` under `/mnt/storage/scratch/tmp/XXXXXX`.
Logs: `/mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log`.
Red tests caught missing scope validation, stale tree admission, derived-product
builtin binding and missing PM schema fields. A preliminary full bus run exposed
an import-closure regression, tester refusal ordering and three managed-launch
timeouts; host injection/refusal ordering fixes passed the targeted 35-test run
and the final full suite. A subsequent full run stalled in the existing scratch
SSH fixture; only its owned test child and scratch daemon were terminated, the
interrupted evidence was kept at `/mnt/storage/scratch/tmp/task5b-bus-interrupted.log`,
and the isolated full rerun passed. All started runs were joined before completion.
Local commit only, no push, no live instance or credential-file access.
+1
View File
@@ -2007,3 +2007,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Proxy args, three-unit copy checks and ordered stop/re-render/start/recovery; run-sheet section 7 updated. Focused 9/9; required upgrade 34/34, case1-accept 53/53, case2-install 28/28, case2-unit 88/88, scratch acceptance 21/21, zero skips; lockfile dependencies restored after initial setup failures. Evidence /mnt/storage/scratch/tmp/JBjOT3/. Linked-worktree queue refusal left to coordinator; local commit only, no push or live/secret access. 2026-11-04 | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Proxy args, three-unit copy checks and ordered stop/re-render/start/recovery; run-sheet section 7 updated. Focused 9/9; required upgrade 34/34, case1-accept 53/53, case2-install 28/28, case2-unit 88/88, scratch acceptance 21/21, zero skips; lockfile dependencies restored after initial setup failures. Evidence /mnt/storage/scratch/tmp/JBjOT3/. Linked-worktree queue refusal left to coordinator; local commit only, no push or live/secret access.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers: 3p->3q migration, PM-only request.question, owner-only request.answer/attach/mode/cancel.console verbs, request-http.mjs routes with owner, same-origin and bounded-body checks, requests.js panel under the owner's chat message (mode choice through the decision confirmation, guided answers, project attach, mode change, cancel, reload-safe lost answer). Bus 1492/1492, webui 538/538, intake+bootstrap 70/70, zero skips; local commit only 2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers: 3p->3q migration, PM-only request.question, owner-only request.answer/attach/mode/cancel.console verbs, request-http.mjs routes with owner, same-origin and bounded-body checks, requests.js panel under the owner's chat message (mode choice through the decision confirmation, guided answers, project attach, mode change, cancel, reload-safe lost answer). Bus 1492/1492, webui 538/538, intake+bootstrap 70/70, zero skips; local commit only
2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Rebased 7fd0c3562 and c52218c65 onto c098fa022 (Task 4 core, #802). Migration renamed 3p-3q to 3q-3r, SCHEMA_VERSION 3r; Task 4's 3p-3q left byte-identical; the kind list is the union (Task 4's prd.submitted, prd.activated, prd.invalidated, mode.changed plus question.asked, question.answered, attach.selected). Task 3's own request.mode.console dropped for Task 4's modeConsole; webui maps request-mode-revision-conflict and reads Task 4's draft shape. Bus 1536/1536, webui 540/540, targeted bus 108/108 and webui 28/28, prd-activation 21/21, project prd 6/6, zero skips; not pushed. 2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Rebased 7fd0c3562 and c52218c65 onto c098fa022 (Task 4 core, #802). Migration renamed 3p-3q to 3q-3r, SCHEMA_VERSION 3r; Task 4's 3p-3q left byte-identical; the kind list is the union (Task 4's prd.submitted, prd.activated, prd.invalidated, mode.changed plus question.asked, question.answered, attach.selected). Task 3's own request.mode.console dropped for Task 4's modeConsole; webui maps request-mode-revision-conflict and reads Task 4's draft shape. Bus 1536/1536, webui 540/540, targeted bus 108/108 and webui 28/28, prd-activation 21/21, project prd 6/6, zero skips; not pushed.
- 2026-11-04 — Codex implementation sub-agent — Lifecycle Task 5b (#797/#1839): shared scoped catalogue admission, explicit validated Stack binding, TaskScope/provenance and Git project guards; based on 103168614 plus cherry-picked Task 4 1627e24d7. Final gates 35/107/132/1501 (+32 configuration/tools), zero skips; logs /mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log. No migration, local commit only, never pushed; no live instance touched.
+6 -2
View File
@@ -237,7 +237,7 @@ an operator inspect content and delivery evidence after an uncertain result.
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes `detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
a receipt with empty `changes`. A refusal is `action.refused` with its code alone. a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. | | `requirements` | Role sessions only, as `task.create` is (row 90, #1584). With `{project_key,requirement_snapshot?}`, returns the exact current approved product snapshot through the shared host `requirementsFor` catalog validator; stale/revoked/foreign snapshots refuse and product reads never fall back. With no args, preserves the legacy response. The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. | | `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. | | `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
@@ -759,7 +759,11 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds
computed at each read and never stored. `priority` is the current row's `fields.priority`, null computed at each read and never stored. `priority` is the current row's `fields.priority`, null
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
first `task.created` for the same Vikunja task id in the business, so a task moved to another first `task.created` for the same Vikunja task id in the business, so a task moved to another
project keeps it; null for a task the stack didn't create. project keeps it; null for a task the stack didn't create. Scoped rows also carry
`project_key` and `requirement_snapshot` from that immutable creation evidence;
these are optional filters on `tasks` and appear in the task's trail. No scope is
invented for older tasks. `checkPush(cap,task_ref,project)` checks that binding
before bundle admission; `recordPush` repeats it, as do review/export consumers.
A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
+106 -21
View File
@@ -95,7 +95,7 @@ export const REPLIES = 50;
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first. // Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
export const STRANDED = 50; export const STRANDED = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most. // Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref']; const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
export const TASK_PAGE = 50; export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200; export const TASK_PAGE_MAX = 200;
// Row 134 (REQ-HARN-2): the web verbs a role holding the "web" tool grant may call, and the label // Row 134 (REQ-HARN-2): the web verbs a role holding the "web" tool grant may call, and the label
@@ -276,6 +276,7 @@ const checkEventId = (mid) => {
}; };
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0'); const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x); const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
const projectKey = x => typeof x === 'string' && ID_PATTERN.test(x);
function keys(x, allowed, required = []) { function keys(x, allowed, required = []) {
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => !(k in x))) if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => !(k in x)))
fail('invalid-request'); fail('invalid-request');
@@ -454,6 +455,10 @@ export class Broker {
#secretCheck; #secretCheck;
#credentialStatus; #credentialStatus;
#requirements; #requirements;
#catalog;
#requestJournal;
#builtin;
#builtinBinding;
#organization; #organization;
#credentialVerbs; #credentialVerbs;
#proxyInit; #proxyInit;
@@ -516,7 +521,7 @@ export class Broker {
#clock = 0; #clock = 0;
#processing; #processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null }) { constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -569,6 +574,12 @@ export class Broker {
this.#roleFile = roleFile; this.#roleFile = roleFile;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here. // Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements(); this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
this.#requestJournal = requirementsHost?.journal;
this.#builtinBinding = requirementsHost?.binding;
this.#builtin = requirementsHost?.builtin;
this.#catalog = requirementsHost?.catalog;
const builtinFor = requirementsHost?.builtinFor;
for (const table of [ for (const table of [
'events', 'events',
'role_claims', 'role_claims',
@@ -598,6 +609,7 @@ export class Broker {
if (typeof requestLifecycle !== 'function') throw new TypeError('requestLifecycle must be a host factory'); if (typeof requestLifecycle !== 'function') throw new TypeError('requestLifecycle must be a host factory');
this.#requests = requestLifecycle({ this.#requests = requestLifecycle({
dataRoot, dataRoot,
builtinBinding: builtinFor,
origin: (identity, request) => this.#requestOrigin(identity, request), origin: (identity, request) => this.#requestOrigin(identity, request),
authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation), authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation),
project: (business, project) => { project: (business, project) => {
@@ -700,19 +712,59 @@ export class Broker {
this.#store.get("SELECT 1 FROM thread_claims c JOIN threads t ON t.id=c.thread WHERE c.business=? AND t.instance=? AND c.holder_run=? AND c.op='claim'", state.business, route.instance, r.by))) fail('prd-authority-refused'); this.#store.get("SELECT 1 FROM thread_claims c JOIN threads t ON t.id=c.thread WHERE c.business=? AND t.instance=? AND c.holder_run=? AND c.op='claim'", state.business, route.instance, r.by))) fail('prd-authority-refused');
return { decision: d.id, resolution_seq: r.seq, by: r.by, via: r.via }; return { decision: d.id, resolution_seq: r.seq, by: r.by, via: r.via };
} }
// Host-only request binding. A numeric tracker board is distinct from the code project key.
taskCreateTarget(cap, args) {
const s = this.#session(cap); this.#agent(s);
return this.#createTarget(s, args);
}
#createTarget(s, args) {
if (!this.taskView(s.business, 'input', args.request)) fail('request-not-found');
if (!this.#requestJournal) fail('requirement-snapshot-required');
const state = this.#requestJournal.readRequest(s.business, args.request);
if (state || args.project_key !== undefined || args.requirement_snapshot !== undefined) {
if (!args.project_key || !args.requirement_snapshot || !args.op) fail('task-scope-required');
if (!state || !state.project || state.project !== args.project_key || state.pm !== s.role) fail('request-project-refused');
const bindings = state.selection?.bindings.filter(b => b.digest === state.plan_digest) ?? [];
if (bindings.length !== 1 || bindings[0].plan.project !== state.project || state.phase === 'cancelled') fail('request-project-refused');
if (state.chat_project) {
const chat = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', state.chat_project, s.business);
if (!chat || chat.repo !== state.project) fail('request-project-refused');
}
const board = bindings[0].plan.tracker.project;
if (args.project !== undefined && args.project !== board) fail('request-tracker-refused');
return board;
}
const binding = this.#builtinBinding;
if (!binding || binding.business !== s.business) fail('requirement-snapshot-required');
// A chat-associated product is a conflicting product binding even without an intake row.
const origin = this.#requestOrigin(s, args.request);
const chat = origin.thread && this.#store.get('SELECT project FROM threads WHERE id=? AND business=?', origin.thread, s.business);
const associated = chat?.project && this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', chat.project, s.business);
if (associated?.repo && associated.repo !== binding.project) fail('request-project-refused');
if (args.project !== undefined && args.project !== binding.tracker_project) fail('request-tracker-refused');
return binding.tracker_project;
}
async requirementsFor(cap, args) { async requirementsFor(cap, args) {
const s = this.#session(cap); const s = this.#session(cap);
try { try { return await this.#requirementsFor(s, args); }
keys(args, ['project_key','requirement_snapshot','requirement'], ['project_key']); catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); }
this.#agent(s); this.#processingGate(s, 'requirements'); }
if (!this.#business(s.business).projects?.[args.project_key]) fail('request-project-refused'); async #requirementsFor(s, args) {
if (!this.#requests?.catalog) fail('requirement-snapshot-required'); keys(args, ['project_key','requirement_snapshot','requirement','request','project','op']);
const result = await this.#requests.catalog.resolve({ business: s.business, project: args.project_key, this.#agent(s); this.#processingGate(s, 'requirements');
...(args.requirement_snapshot === undefined ? {} : { snapshot: args.requirement_snapshot }) }); if (args.request !== undefined) this.#createTarget(s, args);
this.#agent(s); this.#processingGate(s, 'requirements'); const project = args.project_key ?? (args.request !== undefined ? this.#builtinBinding?.project : undefined);
if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement'); if (!projectKey(project) || !this.#business(s.business).projects?.[project] || this.#business(s.business).projects[project].kind === 'library') fail('request-project-refused');
return result; if (args.requirement_snapshot !== undefined && !id(args.requirement_snapshot)) fail('invalid-request');
} catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); } const catalog = this.#requests?.catalog ?? this.#catalog;
if (!catalog) fail('requirement-snapshot-required');
const snapshot = args.requirement_snapshot ?? (args.project_key === undefined ? this.#builtin?.id : undefined);
const result = await catalog.resolve({ business: s.business, project, ...(snapshot === undefined ? {} : { snapshot }) });
this.#agent(s); this.#processingGate(s, 'requirements');
if (args.request !== undefined) this.#createTarget(s, args);
if (args.project_key !== undefined && result.origin !== 'product') fail('requirement-snapshot-required');
if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement');
return result;
} }
// Task 3 (#1839): one owner control as evidence. The message tells the PM; the human.input event names the owner's verified // Task 3 (#1839): one owner control as evidence. The message tells the PM; the human.input event names the owner's verified
// Console subject and takes the message id as its subject, so the PM's message.receive row carries it as the request. // Console subject and takes the message id as its subject, so the PM's message.receive row carries it as the request.
@@ -4278,18 +4330,22 @@ export class Broker {
// didn't create. Both come from append-only rows at each read; nothing is stored. // didn't create. Both come from append-only rows at each read; nothing is stored.
#taskRows(business) { #taskRows(business) {
const taskId = (ref) => ref.slice(ref.indexOf('/') + 1); const taskId = (ref) => ref.slice(ref.indexOf('/') + 1);
const requirement = new Map(); const requirement = new Map(), scopes = new Map();
for (const e of this.#store.all( for (const e of this.#store.all(
"SELECT subject,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq", "SELECT subject,body,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq",
business, business,
)) ))
if (!requirement.has(taskId(e.subject))) requirement.set(taskId(e.subject), e.requirement); if (!requirement.has(taskId(e.subject))) {
requirement.set(taskId(e.subject), e.requirement);
const b = JSON.parse(e.body);
if (b.scope) scopes.set(taskId(e.subject), b.scope);
}
return this.#store return this.#store
.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business) .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business)
.map((r) => { .map((r) => {
const fields = JSON.parse(r.fields); const fields = JSON.parse(r.fields);
const priority = fields.priority ?? null; const priority = fields.priority ?? null;
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null }; return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null, ...(scopes.get(taskId(r.task_ref)) ?? {}) };
}); });
} }
// Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and // Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and
@@ -4311,6 +4367,8 @@ export class Broker {
('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) || ('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) ||
('label' in a && !positive(a.label)) || ('label' in a && !positive(a.label)) ||
('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) || ('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) ||
('project_key' in a && !projectKey(a.project_key)) ||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) || ('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after)) ('after' in a && !taskRef(a.after))
) )
@@ -4322,7 +4380,7 @@ export class Broker {
); );
const matched = rows const matched = rows
.filter((r) => !r.fields.gone) .filter((r) => !r.fields.gone)
.map(({ task_ref, fields: f, priority, requirement, at }) => ({ .map(({ task_ref, fields: f, priority, requirement, project_key, requirement_snapshot, at }) => ({
task_ref, task_ref,
title: f.title ?? null, title: f.title ?? null,
done: Boolean(f.done), done: Boolean(f.done),
@@ -4331,6 +4389,7 @@ export class Broker {
labels: f.labels ?? [], labels: f.labels ?? [],
priority, priority,
requirement, requirement,
...(project_key === undefined ? {} : { project_key, requirement_snapshot }),
changed_at: at, changed_at: at,
})) }))
.filter( .filter(
@@ -4338,7 +4397,9 @@ export class Broker {
(!('done' in a) || t.done === a.done) && (!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) && (!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) && (!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement), (!('requirement' in a) || t.requirement === a.requirement) &&
(!('project_key' in a) || t.project_key === a.project_key) &&
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
); );
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched; const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
const limit = a.limit ?? TASK_PAGE; const limit = a.limit ?? TASK_PAGE;
@@ -4469,16 +4530,26 @@ export class Broker {
business, business,
task, task,
); );
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null; if (!r) return null;
const push = { ...JSON.parse(r.body), role: r.actor_role };
this.#taskProject(business, task, push.project);
return push;
} }
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded // Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved. // unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
checkPush(cap, task_ref) { #taskProject(business, task_ref, project) {
const taskId = task_ref.slice(task_ref.indexOf('/') + 1);
const row = this.#store.get("SELECT body FROM events WHERE business=? AND kind='task.created' AND substr(subject,instr(subject,'/')+1)=? ORDER BY seq LIMIT 1", business, taskId);
const body = row && JSON.parse(row.body), scope = body?.scope ?? (body?.project_key ? body : null);
if (scope && (scope.project_key !== project || !Object.hasOwn(this.#business(business).projects ?? {}, project))) fail('task-project-refused');
}
checkPush(cap, task_ref, project) {
this.#processingWork(); this.#processingWork();
const s = this.#session(cap); const s = this.#session(cap);
return this.#store.transaction(() => { return this.#store.transaction(() => {
this.#agent(s); this.#agent(s);
if (!taskRef(task_ref)) fail('invalid-request'); if (!taskRef(task_ref)) fail('invalid-request');
this.#taskProject(s.business, task_ref, project);
return this.#checkAuthority(s, 'git.push.working', { target: task_ref }); return this.#checkAuthority(s, 'git.push.working', { target: task_ref });
}); });
} }
@@ -4499,6 +4570,7 @@ export class Broker {
string(push.branch, 200); string(push.branch, 200);
string(push.ref, 200); string(push.ref, 200);
const { task_ref, project, branch, ref, commit, previous } = push; const { task_ref, project, branch, ref, commit, previous } = push;
this.#taskProject(s.business, task_ref, project);
this.#consumeAuthority(s, 'git.push.working', { target: task_ref }, { project, branch, ref, commit, previous }); this.#consumeAuthority(s, 'git.push.working', { target: task_ref }, { project, branch, ref, commit, previous });
return { task_ref, commit }; return { task_ref, commit };
}); });
@@ -4621,6 +4693,7 @@ export class Broker {
string(args.branch, 200); string(args.branch, 200);
this.#agent(s); this.#agent(s);
const { task_ref, project, branch } = args; const { task_ref, project, branch } = args;
this.#taskProject(s.business, task_ref, project);
const decision = args.decision ?? null; const decision = args.decision ?? null;
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref }); const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
const facts = this.#publishFacts(s, task_ref, project, result); const facts = this.#publishFacts(s, task_ref, project, result);
@@ -4636,6 +4709,7 @@ export class Broker {
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no // Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip. // decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
#publishFacts(s, task_ref, project, result) { #publishFacts(s, task_ref, project, result) {
this.#taskProject(s.business, task_ref, project);
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed'); if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
const b = this.#business(s.business); const b = this.#business(s.business);
const role = b.roles?.[s.role]; const role = b.roles?.[s.role];
@@ -4928,6 +5002,7 @@ export class Broker {
); );
const body = r ? JSON.parse(r.body) : null; const body = r ? JSON.parse(r.body) : null;
if (!body || body.to !== s.role) fail('request-not-found'); if (!body || body.to !== s.role) fail('request-not-found');
this.#taskProject(s.business, task, body.project);
return { ...body, from: r.actor_role }; return { ...body, from: r.actor_role };
} }
// Trusted adapters only. No agent socket route reaches this method. // Trusted adapters only. No agent socket route reaches this method.
@@ -5007,6 +5082,7 @@ export class Broker {
this.#snapshot(s, kept); this.#snapshot(s, kept);
this.#event(s, 'task.created', { this.#event(s, 'task.created', {
request: p.request, requirement: p.requirement, op: p.op, create_receipt: r.id, create_body_digest: p.body_digest, request: p.request, requirement: p.requirement, op: p.op, create_receipt: r.id, create_body_digest: p.body_digest,
...(p.evidence ?? {}),
...(p.project_key === undefined ? {} : { project_key: p.project_key }), ...(p.project_key === undefined ? {} : { project_key: p.project_key }),
...(p.requirement_snapshot === undefined ? {} : { requirement_snapshot: p.requirement_snapshot }), ...(p.requirement_snapshot === undefined ? {} : { requirement_snapshot: p.requirement_snapshot }),
}, task_ref); }, task_ref);
@@ -5197,6 +5273,15 @@ export class Broker {
? this.#requests.catalog.revoke(s, structuredClone(args)) : this.#requests.modeConsole(s, structuredClone(args))) ? this.#requests.catalog.revoke(s, structuredClone(args)) : this.#requests.modeConsole(s, structuredClone(args)))
.catch(e => { throw this.#refused(s, new BusError(e.code ?? 'request-failed'), request); }); .catch(e => { throw this.#refused(s, new BusError(e.code ?? 'request-failed'), request); });
} }
// Scoped discovery performs committed Git reads, outside the synchronous SQL dispatch.
if (request.verb === 'requirements' && Object.keys(args).length) {
if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused');
keys(args, ['project_key','requirement_snapshot'], ['project_key']);
return this.#requirementsFor(s, structuredClone(args))
.then(result => { this.#secretCheck(result); return result; })
.catch(e => { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), request); });
}
if (Object.hasOwn(REQUEST_VERBS, request.verb)) { if (Object.hasOwn(REQUEST_VERBS, request.verb)) {
if (s.reader) fail('read-only'); if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused'); if (s.tester) fail('tester-refused');
+1 -1
View File
@@ -46,7 +46,7 @@ function launchOp(m) {
if (m.op === 'endLaunch') return runtime.endLaunch(m.record); if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record); if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record);
// Row 93 (#1587): the git socket's ops. // Row 93 (#1587): the git socket's ops.
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref); if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref, m.project);
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push); if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args); if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args);
// Row 137 (#1631): the git socket's land op, and the launcher's question whether to bind it. // Row 137 (#1631): the git socket's land op, and the launcher's question whether to bind it.
+2 -1
View File
@@ -19,10 +19,11 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
exact(args, ['business','project'], ['snapshot']); exact(args, ['business','project'], ['snapshot']);
if (!key(args.business) || !key(args.project) || (args.snapshot !== undefined && !id(args.snapshot))) refuse('invalid-request'); if (!key(args.business) || !key(args.project) || (args.snapshot !== undefined && !id(args.snapshot))) refuse('invalid-request');
const { business, project } = args; const { business, project } = args;
const builtin = await builtinBinding?.({ business, project });
if (args.snapshot && builtin?.id === args.snapshot) return structuredClone(builtin);
const events = journal.snapshotEvents(business, project); const events = journal.snapshotEvents(business, project);
const snapshot = args.snapshot ?? [...events].reverse().find(e => e.kind === 'activated')?.snapshot; const snapshot = args.snapshot ?? [...events].reverse().find(e => e.kind === 'activated')?.snapshot;
if (!snapshot) { if (!snapshot) {
const builtin = await builtinBinding?.({ business, project });
if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin; if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin;
refuse('requirement-snapshot-required'); refuse('requirement-snapshot-required');
} }
+6 -2
View File
@@ -1,3 +1,4 @@
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs'; import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs'; import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs'; import { lstatSync, readdirSync, readFileSync } from 'node:fs';
@@ -267,6 +268,7 @@ export async function startBroker({
// The boot config's plain-data tracker settings, { business: { project, ... } }. The tasks adapter reads its own copy; // The boot config's plain-data tracker settings, { business: { project, ... } }. The tasks adapter reads its own copy;
// the host's new-project preparation reads each business's tracker root board from this one. // the host's new-project preparation reads each business's tracker root board from this one.
trackers = null, trackers = null,
builtinBinding = null,
}) { }) {
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
@@ -580,7 +582,9 @@ export async function startBroker({
} }
return no(declared ? 'project-pending' : 'project-unknown'); return no(declared ? 'project-pending' : 'project-unknown');
} }
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
broker = new Broker({ broker = new Broker({
requirementsHost,
requestLifecycle: services => { requestLifecycle: services => {
const readCandidate = (candidate, business) => { const readCandidate = (candidate, business) => {
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate; const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
@@ -589,7 +593,7 @@ export async function startBroker({
}; };
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval }, const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
builtinBinding: null }); builtinBinding: services.builtinBinding });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services, return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait. // The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: { ...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
@@ -849,7 +853,7 @@ export async function startBroker({
refusing(cap, () => broker.authorize(cap, 'role.launch', { target: instance })); refusing(cap, () => broker.authorize(cap, 'role.launch', { target: instance }));
// Row 93 (#1587): the host's git socket. A push is checked before the host takes the bundle // Row 93 (#1587): the host's git socket. A push is checked before the host takes the bundle
// and recorded once the ref moved; a reviewer's fetch is checked against its review.requested. // and recorded once the ref moved; a reviewer's fetch is checked against its review.requested.
const checkPush = (cap, task_ref) => refusing(cap, () => broker.checkPush(cap, task_ref), task_ref); const checkPush = (cap, task_ref, project) => refusing(cap, () => broker.checkPush(cap, task_ref, project), task_ref);
const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref); const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref);
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's. // Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap. // A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
const A = 'a'.repeat(40);
const push = (f, task, project) => ({ task_ref: task.task_ref, project, branch: 'feat', ref: 'refs/heads/work', commit: A, previous: null });
test('checkPush binds a product before bytes and recordPush repeats the project check', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args);
const b = f.bus.broker;
for (const project of ['stack', undefined, 'missing']) assert.throws(() => b.checkPush(f.caps.coder, task.task_ref, project), { code: 'task-project-refused' });
b.checkPush(f.caps.coder, task.task_ref, 'timer');
assert.throws(() => b.recordPush(f.caps.coder, push(f, task, 'stack')), { code: 'task-project-refused' });
b.recordPush(f.caps.coder, push(f, task, 'timer'));
const review = f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' });
assert.equal(b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: review.request }).project, 'timer');
await f.restart();
assert.throws(() => f.bus.checkPush(f.caps.coder, task.task_ref, 'stack'), { code: 'task-project-refused' });
f.bus.checkPush(f.caps.coder, task.task_ref, 'timer');
});
test('review, review export and remote export reject old cross-project evidence for a scoped task', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args), b = f.bus.broker;
// Host-written historical evidence can predate scope admission; consumers must still check it.
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(f.f.dataRoot + '/bus/bus.sqlite');
db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-push',?,'demo','coder','coder-run','action.allowed',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ action: 'git.push.working', project: 'stack', commit: A, ref: 'refs/heads/work' }));
db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-review',?,'demo','coder','coder-run','review.requested',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ to: 'reviewer', project: 'stack', commit: A, ref: 'refs/heads/work' }));
db.close();
assert.throws(() => f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' }), { code: 'task-project-refused' });
assert.throws(() => b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: 'old-review' }), { code: 'task-project-refused' });
assert.throws(() => b.checkPublish(f.caps.coder, { task_ref: task.task_ref, project: 'stack', branch: 'feat' }), { code: 'task-project-refused' });
});
test('legacy recorded tasks still admit a push without inventing scope', async t => {
const f = await scopedFixture(t), task = { task_ref: 'vikunja:1/789' }, b = f.bus.broker;
b.recordTask({ cap: f.caps.pm, business: 'demo', events: [{ kind: 'task.created', subject: task.task_ref, body: { request: f.instruction(), requirement: 'REQ-TASK-1' } }] });
b.checkPush(f.caps.coder, task.task_ref);
b.recordPush(f.caps.coder, push(f, task, 'stack'));
assert.equal(f.call('coder', 'trail', { subject: task.task_ref }).find(e => e.body?.action === 'git.push.working').body.scope, undefined);
});
+7 -3
View File
@@ -3,6 +3,7 @@
// does), the business file, and the boot message for the broker process. // does), the business file, and the boot message for the broker process.
// Everything here reads and refuses; nothing writes. // Everything here reads and refuses; nothing writes.
import { stackBinding } from "../../tasks/src/builtin.mjs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs"; import { existsSync, lstatSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
@@ -81,13 +82,13 @@ function trackerFor(system, b, warn, dataRoot) {
return null; return null;
} }
const v = named[0].vars; const v = named[0].vars;
return { return { tracker: {
baseUrl: v["tracker.baseUrl"], baseUrl: v["tracker.baseUrl"],
project: v["tracker.project"], project: v["tracker.project"],
pollSeconds: v["tracker.pollSeconds"], pollSeconds: v["tracker.pollSeconds"],
reconcileMinutes: v["tracker.reconcileMinutes"], reconcileMinutes: v["tracker.reconcileMinutes"],
descendants: v["tracker.descendants"], descendants: v["tracker.descendants"],
}; }, builtinBinding: stackBinding({ business: b.id, project: named[0].id, root: b.projects[named[0].id].root, tracker_project: v["tracker.project"] }) };
} }
// FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file // FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file
@@ -142,7 +143,10 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
readers: [b.id], readers: [b.id],
}; };
const tracker = trackerFor(vars, b, warn, system.dataRoot); const tracker = trackerFor(vars, b, warn, system.dataRoot);
if (tracker) config.trackers = { [b.id]: tracker }; if (tracker) {
config.trackers = { [b.id]: tracker.tracker };
if (tracker.builtinBinding) config.builtinBinding = tracker.builtinBinding;
}
const access = accessFor(system, env, warn); const access = accessFor(system, env, warn);
if (access) config.access = access; if (access) config.access = access;
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub. // Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
+1 -1
View File
@@ -324,7 +324,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`); if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`);
// The runner waits for "send"; bytes before it break the protocol. // The runner waits for "send"; bytes before it break the protocol.
if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send"); if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send");
await op({ op: "checkPush", cap, task_ref }); await op({ op: "checkPush", cap, task_ref, project: req.project });
socket.write(`${JSON.stringify({ ok: true, send: size })}\n`); socket.write(`${JSON.stringify({ ok: true, send: size })}\n`);
await readBody(socket, file, size, deadlineOf(size), bodyIdle); await readBody(socket, file, size, deadlineOf(size), bodyIdle);
const result = await push({ const result = await push({
+14
View File
@@ -233,6 +233,20 @@ test("refusals: no action, non-fast-forward, an unknown commit or request, and a
assert.ok(refused.includes(r), `${r} in ${refused.join(", ")}`); assert.ok(refused.includes(r), `${r} in ${refused.join(", ")}`);
}); });
test("a scoped task refuses the wrong code project before the socket asks for bundle bytes", async (t) => {
const { DatabaseSync } = await import("node:sqlite");
let admission;
const w = await setup(t, { wrap: host => ({ op: m => { if (m.op === "checkPush") admission = m; return host.op(m); } }) });
const db = new DatabaseSync(join(w.f.dataRoot, "bus/bus.sqlite"));
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('fixture',?,'acme','human.input','{}')").run(new Date().toISOString());
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('scoped-create',?,'acme','task.created',?,?)")
.run(new Date().toISOString(), TASK, JSON.stringify({ request: "fixture", requirement: "REQ-POMO-1", scope: { project_key: "timer", requirement_snapshot: "prd-fixture", requirement: "REQ-POMO-1" } }));
db.close();
const answer = await raw(w.sock.path, JSON.stringify({ op: "push", cap: w.roles.coder.cap, task_ref: TASK, project: "stack", branch: "feat", size: 100 }) + "\n");
assert.equal(answer.ok, false); assert.equal(answer.error, "task-project-refused"); assert.equal(answer.send, undefined);
assert.equal(admission.project, "stack"); assert.equal(w.refs(), "");
});
test("the size cap refuses a push before any byte", async (t) => { test("the size cap refuses a push before any byte", async (t) => {
const w = await setup(t, { bundleMax: 64 }); const w = await setup(t, { bundleMax: 64 });
gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat"); gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat");
+10 -2
View File
@@ -48,7 +48,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori
const TASK_TOOLS = { const TASK_TOOLS = {
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", { "task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
title: str("Task title, one line"), title: str("Task title, one line"),
project: { type: "integer", minimum: 1, description: "Tracker project id, an installed project in the business's tree; the root project when left out" }, project: { type: "integer", minimum: 1, description: "Optional tracker board assertion; must match the confirmed request binding" },
description: str("Task description"), description: str("Task description"),
due_date: DUE, due_date: DUE,
priority: PRIORITY, priority: PRIORITY,
@@ -56,6 +56,9 @@ const TASK_TOOLS = {
relations: { type: "array", items: RELATION, maxItems: 50 }, relations: { type: "array", items: RELATION, maxItems: 50 },
request: str("The request id of the human message this task answers, shown as \"request <id>\" in that message's header; not the message's own id"), request: str("The request id of the human message this task answers, shown as \"request <id>\" in that message's header; not the message's own id"),
requirement: str("Requirement id from list_requirements, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), requirement: str("Requirement id from list_requirements, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
project_key: str("Product code project key; required for every intake request"),
requirement_snapshot: str("Exact approved snapshot returned by list_requirements; required for product tasks"),
op: str("Stable operation key; required for product tasks and reused for all retries of the same create"),
decision: COMMON.decision, decision: COMMON.decision,
}, ["title", "request", "requirement"]], }, ["title", "request", "requirement"]],
"task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]], "task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
@@ -332,13 +335,18 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
assignee: str("Only tasks assigned to this role"), assignee: str("Only tasks assigned to this role"),
label: { type: "integer", minimum: 1, description: "Only tasks with this label id" }, label: { type: "integer", minimum: 1, description: "Only tasks with this label id" },
requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
project_key: str("Only tasks bound to this code project, including when requirement IDs collide"),
requirement_snapshot: str("Only tasks created from this exact requirement snapshot"),
limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` }, limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` },
after: str("The next value of the previous page"), after: str("The next value of the previous page"),
})); }));
add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"])); add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"]));
// Row 90 (#1584): the ids task_create takes, for the sessions that hold it. // Row 90 (#1584): the ids task_create takes, for the sessions that hold it.
if (held.has("task.create")) if (held.has("task.create"))
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({})); add("list_requirements", "requirements", null, "Read the exact approved product snapshot by project_key, optionally requirement_snapshot; task_create takes only these requirements and must cite that snapshot. With no args, list the legacy Stack catalogue. Product intake always needs an approved product snapshot.", obj({
project_key: str("Product code project key"),
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
}));
} }
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The // mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
// C2-08 web grant still supplies broker web reads to companion and research only. // C2-08 web grant still supplies broker web reads to companion and research only.
+15 -3
View File
@@ -232,9 +232,10 @@ test("list_requirements reads the requirement ids, for a task.create holder with
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true }); const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
const list = tools.find((x) => x.name === "list_requirements"); const list = tools.find((x) => x.name === "list_requirements");
assert.deepEqual([list.verb, list.action], ["requirements", null]); assert.deepEqual([list.verb, list.action], ["requirements", null]);
assert.deepEqual(list.parameters.properties, {}); assert.deepEqual(Object.keys(list.parameters.properties), ["project_key", "requirement_snapshot"]);
assert.deepEqual(list.parameters.required, []);
assert.match(list.description, /task_create takes only these/); assert.match(list.description, /task_create takes only these/);
assert.match(list.description, /raise a prd\.approve decision instead of creating the task/); assert.match(list.description, /Product intake always needs an approved product snapshot/);
assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements")); assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements"));
const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"])); const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"]));
const without = names(typedTools({ resolved: noCreate, tracker: true })); const without = names(typedTools({ resolved: noCreate, tracker: true }));
@@ -359,7 +360,7 @@ test("list_tasks takes the broker's filters and pages; get_task takes a task_ref
const get = tools.find((x) => x.name === "get_task"); const get = tools.find((x) => x.name === "get_task");
assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]); assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]);
const p = list.parameters; const p = list.parameters;
assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]); assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "project_key", "requirement_snapshot", "limit", "after"]);
assert.deepEqual(p.required, []); assert.deepEqual(p.required, []);
assert.equal(p.properties.done.type, "boolean"); assert.equal(p.properties.done.type, "boolean");
assert.equal(p.properties.assignee.type, "string"); assert.equal(p.properties.assignee.type, "string");
@@ -411,3 +412,14 @@ test("web_search's query maxLength is the web layer's SEARCH_MAX_QUERY_CHARS", a
const search = typedTools({ resolved: granted }).find((x) => x.name === "web_search"); const search = typedTools({ resolved: granted }).find((x) => x.name === "web_search");
assert.equal(search.parameters.properties.query.maxLength, SEARCH_MAX_QUERY_CHARS); assert.equal(search.parameters.properties.query.maxLength, SEARCH_MAX_QUERY_CHARS);
}); });
test("PM tools carry the product snapshot and stable create operation, and filter colliding requirements by project", t => {
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
const params = name => tools.find(t => t.name === name).parameters;
for (const field of ["project_key", "requirement_snapshot", "op"]) assert.equal(params("task_create").properties[field]?.type, "string");
for (const name of ["list_requirements", "list_tasks"]) {
assert.equal(params(name).properties.project_key?.type, "string");
assert.equal(params(name).properties.requirement_snapshot?.type, "string");
}
assert.deepEqual(params("task_create").required, ["title", "request", "requirement"]);
});
+36 -13
View File
@@ -141,8 +141,24 @@ the reviewed source. `tests/requirements.test.mjs` reads its
holds the same ids in the same order at the same version. A later PRD holds the same ids in the same order at the same version. A later PRD
version is a new list in a reviewed commit. `loadRequirements()` version is a new list in a reviewed commit. `loadRequirements()`
(`src/requirements.mjs`) checks the file's shape, refuses any other and (`src/requirements.mjs`) checks the file's shape, refuses any other and
returns it frozen; the adapter loads it when it starts, and the bus returns it frozen; the broker loads it at startup and preserves the unscoped
loads it for its `requirements` read. Neither starts on a bad file. `requirements` response. A bad catalogue refuses startup. `bootConfig` passes an
explicit `{business,project,tracker_project}` builtin binding only after checking
the code project's committed identity, Stack foundation ancestry, and exact
repository-owned PRD/catalogue bytes. A name, requirement prefix or board number
cannot establish that binding. New legacy creates use its baked version/hash;
existing recorded tasks gain no scope retroactively.
Product reads and creates use `Broker.requirementsFor` and the Task 4 catalogue.
`requirements({project_key,requirement_snapshot?})` returns the exact approved
snapshot, including PRD version, records commit and spec commit. A product call
never falls back to the baked Stack catalogue. Creation checks that snapshot
while planning and immediately before admission, with current session/role
authority and refreshed tracker membership/installation. `task.created` records
`scope: {project_key,requirement_snapshot,requirement}`, source version/commits
and `op`. Task filters accept `project_key` and `requirement_snapshot` so identical
requirement IDs in different products remain distinct in task lists and trails.
Scoped pushes, reviews and exports must use the task's bound code project.
A write that answers 5xx or doesn't answer is never retried. The adapter A write that answers 5xx or doesn't answer is never retried. The adapter
reads the task again. If the write took effect, it continues. If not, reads the task again. If the write took effect, it continues. If not,
@@ -199,11 +215,15 @@ Details that aren't obvious from the table:
- `task.create` needs `request`, the id of a `human.input` event of this - `task.create` needs `request`, the id of a `human.input` event of this
business (`request-not-found` otherwise), and a `requirement` from business (`request-not-found` otherwise), and a `requirement` from
`requirements.json`, like `REQ-TASK-3`. An id in the right form that its approved catalogue snapshot, like `REQ-TASK-3`. An id in the right form
the list doesn't hold refuses `unknown-requirement`. The `request` is that the snapshot does not hold refuses `unknown-requirement`. The `request` is
the `request` field `message.receive` gives a human's message, not the the `request` field `message.receive` gives a human's message, not the
message's own id. `project` is the tracker project id to create the message's own id. Every intake request, including selection and discovery,
task in, the root when left out. Any other project than an installed requires `project_key`, `requirement_snapshot` and a stable `op`; missing fields
refuse `task-scope-required`. The request's unique confirmed binding resolves
the tracker board. `project`, when supplied, asserts that numeric board;
a mismatch refuses `request-tracker-refused`. Unscoped legacy calls require
the explicit Stack binding and no conflicting source product association. Any other project than an installed
one of the tree refuses with `project-not-installed`, and a value that one of the tree refuses with `project-not-installed`, and a value that
isn't a positive integer with `invalid-request`. The decision a create isn't a positive integer with `invalid-request`. The decision a create
may need is bound to no target, so it doesn't name the project. may need is bound to no target, so it doesn't name the project.
@@ -238,13 +258,12 @@ so a poll never reads between two writes of a verb.
## Recoverable creates (lifecycle Task 5a) ## Recoverable creates (lifecycle Task 5a)
A lifecycle caller supplies a stable `op` with `task.create` and reuses it for A product caller supplies `project_key`, `requirement_snapshot` and a stable
all retries of that exact request and body. Existing calls without the new `op` with `task.create`, reusing all three for retries of that exact request and
fields keep their previous behavior. Optional `project_key` and body. The adapter validates the approved snapshot and request binding; it never
`requirement_snapshot` are opaque strings carried into evidence; Task 5b will generates an omitted product operation key. Unscoped legacy calls retain the
validate their catalogue binding. If either is supplied without `op`, the existing response/body behavior through the explicit Stack binding and can opt
adapter generates a key, recorded in `task.created` evidence. Supply your own into recovery by supplying `op`.
key to retry safely after a lost response.
The adapter adds this visible suffix to the description, omitting absent fields: The adapter adds this visible suffix to the description, omitting absent fields:
@@ -436,6 +455,10 @@ on a live read with the sync key, and a switch is a follow-up row.
| `tracker-unavailable` | 5xx or no answer on a read | | `tracker-unavailable` | 5xx or no answer on a read |
| `task-create-uncertain` | reserved operation requires reconciliation; never authorizes another POST | | `task-create-uncertain` | reserved operation requires reconciliation; never authorizes another POST |
| `task-create-conflict` | an operation key or recovery receipt has a different identity, body or target | | `task-create-conflict` | an operation key or recovery receipt has a different identity, body or target |
| `task-scope-required` | an intake/product create omitted its code project, snapshot or operation |
| `requirement-snapshot-required`, `requirement-snapshot-refused`, `requirement-snapshot-revoked` | no approved catalogue binding, or an invalid/foreign/stale snapshot |
| `request-project-refused`, `request-tracker-refused` | the request's confirmed product or tracker board does not match the create |
| `task-project-refused` | a scoped task's Git operation names another code project |
| `write-uncertain` | 5xx or no answer on a write, not settled by a re-read | | `write-uncertain` | 5xx or no answer on a write, not settled by a re-read |
| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show | | `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show |
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | | `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
+48
View File
@@ -0,0 +1,48 @@
// Host startup only: bind the baked Stack catalogue to a proven Stack code project, never a name or board heuristic.
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { loadRequirements, REQUIREMENTS_FILE } from './requirements.mjs';
import { createRequestStore, exact, key, refuse } from '../../bus/src/request-store.mjs';
import { createRequirementCatalog } from '../../bus/src/requirement-catalog.mjs';
import { MOSAIC_ROOT_COMMIT } from '../../seat/src/landing.mjs';
export function stackBinding({ business, project, root, tracker_project }) {
try {
if (!Number.isSafeInteger(tracker_project) || tracker_project < 1) return null;
const git = (...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
encoding: 'utf8', maxBuffer: 1024 * 1024, timeout: 10000, stdio: ['ignore','pipe','pipe'],
env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_NO_REPLACE_OBJECTS: '1' },
});
git('merge-base', '--is-ancestor', MOSAIC_ROOT_COMMIT, 'HEAD');
const identity = JSON.parse(git('show', 'HEAD:.mosaic/project.json'));
// Factory-derived products retain Stack history and files; the committed Stack identity must also match.
if (identity.projectVersion !== 1 || identity.id !== 'stack' || identity.id !== project) return null;
const catalog = loadRequirements();
for (const [path, baked] of [['packages/tasks/requirements.json', readFileSync(REQUIREMENTS_FILE, 'utf8')],
[catalog.prd, readFileSync(new URL('../../../' + catalog.prd, import.meta.url), 'utf8')]]) {
if (git('ls-tree', 'HEAD', '--', path).split(' ')[0] !== '100644' || git('show', `HEAD:${path}`) !== baked) return null;
}
return { business, project, tracker_project };
} catch { return null; }
}
export function builtinSnapshot(binding) {
const catalog = loadRequirements();
const catalog_sha256 = createHash('sha256').update(readFileSync(REQUIREMENTS_FILE)).digest('hex');
return { origin: 'builtin', id: `builtin-${catalog_sha256}`, business: binding.business, project: binding.project, ...catalog, catalog_sha256 };
}
// Host injection keeps Git/filesystem catalogue dependencies out of Broker's sandbox import closure.
export function createRequirementsHost({ store, businesses, builtinBinding = null, journal = createRequestStore(store) }) {
let builtin = null;
if (builtinBinding !== null) {
exact(builtinBinding, ['business','project','tracker_project']);
const entry = businesses[builtinBinding.business]?.projects?.[builtinBinding.project];
if (!key(builtinBinding.business) || !key(builtinBinding.project) || !entry || entry.kind === 'library' ||
!stackBinding({ ...builtinBinding, root: entry.root })) refuse('builtin-binding-refused');
builtin = builtinSnapshot(builtinBinding);
}
const builtinFor = ({ business, project }) => builtin?.business === business && builtin.project === project ? structuredClone(builtin) : null;
return { journal, builtin, binding: builtinBinding && structuredClone(builtinBinding), builtinFor,
catalog: createRequirementCatalog({ journal, builtinBinding: builtinFor, readSpec: () => refuse('requirement-snapshot-required') }) };
}
+43 -19
View File
@@ -1,10 +1,9 @@
import { randomUUID } from 'node:crypto';
import { reconcileCreate } from './create-recovery.mjs'; import { reconcileCreate } from './create-recovery.mjs';
import { BusError, createMarker, createDigest } from '../../bus/src/broker.mjs'; import { BusError, createMarker, createDigest } from '../../bus/src/broker.mjs';
import { outcome, refusal } from './vikunja.mjs'; import { outcome, refusal } from './vikunja.mjs';
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs'; import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
import { board, normal } from './sync.mjs'; import { board, normal } from './sync.mjs';
import { loadRequirements } from './requirements.mjs'; import { discover, inspect } from './startup.mjs';
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with // The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's // the sync token, the caller's expected digest, broker authority, the writes with the acting role's
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at // own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
@@ -67,8 +66,9 @@ const sorted = (a) => [...a].sort((x, y) => x - y);
const decision = (x) => (x === undefined ? null : text(x, 128, 1)); const decision = (x) => (x === undefined ? null : text(x, 128, 1));
export function verbs(ctx) { export function verbs(ctx) {
const fail = (code) => refuse(code); const fail = (code) => refuse(code);
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start. const scopeArgs = plan => ({ request: plan.request, requirement: plan.requirement,
const requirements = new Set(loadRequirements().requirements.map((r) => r.id)); ...(plan.project === undefined ? {} : { project: plan.project }),
...Object.fromEntries(['project_key','requirement_snapshot','op'].filter(k => plan[k] !== undefined).map(k => [k, plan[k]])) });
const label = (id) => { const label = (id) => {
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file. // Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
if (!ctx.labels.has(id)) fail('label-not-allowed'); if (!ctx.labels.has(id)) fail('label-not-allowed');
@@ -203,12 +203,13 @@ export function verbs(ctx) {
switch (verb) { switch (verb) {
case 'task.create': { case 'task.create': {
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project', 'op', 'project_key', 'requirement_snapshot'], ['title', 'request', 'requirement']); keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project', 'op', 'project_key', 'requirement_snapshot'], ['title', 'request', 'requirement']);
// Mos's ruling: an optional project, which must be an installed project of the tree. Without // The optional numeric project is only a board assertion. The broker resolves the target
// it the task goes to the root. // from the confirmed request (or the explicitly bound legacy Stack catalogue).
const project = args.project === undefined ? ctx.project : args.project; if (args.project !== undefined) {
if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request'); if (!Number.isSafeInteger(args.project) || args.project < 1) fail('invalid-request');
if (!ctx.tree.get(project)) fail('project-not-installed'); if (!ctx.tree.get(args.project)) fail('project-not-installed');
plan.at = { project, view: ctx.tree.get(project) }; }
plan.project = args.project;
const body = { title: text(args.title, 250, 1) }; const body = { title: text(args.title, 250, 1) };
if (!body.title.trim()) fail('invalid-request'); if (!body.title.trim()) fail('invalid-request');
if (args.description !== undefined) body.description = text(args.description, 65536); if (args.description !== undefined) body.description = text(args.description, 65536);
@@ -222,18 +223,11 @@ export function verbs(ctx) {
plan.request = text(args.request, 64, 1); plan.request = text(args.request, 64, 1);
plan.requirement = text(args.requirement, 64); plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
for (const field of ['op', 'project_key', 'requirement_snapshot']) for (const field of ['op', 'project_key', 'requirement_snapshot'])
if (args[field] !== undefined) { if (args[field] !== undefined) {
plan[field] = text(args[field], 256, 1); plan[field] = text(args[field], 256, 1);
if (/[;\r\n]/.test(plan[field])) fail('invalid-request'); if (/[;\r\n]/.test(plan[field])) fail('invalid-request');
} }
// Existing unscoped calls retain their established body and evidence. The new lifecycle
// opts into recovery with an op; scope-only callers get a generated one.
if (plan.op !== undefined || plan.project_key !== undefined || plan.requirement_snapshot !== undefined) {
plan.op ??= randomUUID();
plan.body.description = (body.description ? body.description + '\n\n' : '') + createMarker(plan);
}
return plan; return plan;
} }
case 'task.assign': case 'task.assign':
@@ -406,6 +400,7 @@ export function verbs(ctx) {
op: plan.op, request: plan.request, requirement: plan.requirement, decision: plan.decision, op: plan.op, request: plan.request, requirement: plan.requirement, decision: plan.decision,
project: plan.at.project, view: plan.at.view, creator: ctx.roles[me.role].botId, project: plan.at.project, view: plan.at.view, creator: ctx.roles[me.role].botId,
body: plan.body, body_digest: createDigest(plan.body), labels: plan.labels, relations: plan.relations, body: plan.body, body_digest: createDigest(plan.body), labels: plan.labels, relations: plan.relations,
evidence: plan.evidence,
...(plan.project_key === undefined ? {} : { project_key: plan.project_key }), ...(plan.project_key === undefined ? {} : { project_key: plan.project_key }),
...(plan.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }), ...(plan.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }),
}; };
@@ -413,6 +408,7 @@ export function verbs(ctx) {
let taskRef; let taskRef;
if (fresh) { if (fresh) {
await located(plan.others); await located(plan.others);
await admit(cap, plan);
ctx.broker.prepareTaskCreate(cap, prepared); ctx.broker.prepareTaskCreate(cap, prepared);
const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body }); const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body });
if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) { if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) {
@@ -441,11 +437,13 @@ export function verbs(ctx) {
if (!after.task.done) { if (!after.task.done) {
await located(plan.others); await located(plan.others);
for (const l of plan.labels) if (!after.fields.labels.includes(l)) { for (const l of plan.labels) if (!after.fields.labels.includes(l)) {
await admit(cap, plan);
ctx.broker.prepareTaskCreate(cap, prepared); ctx.broker.prepareTaskCreate(cap, prepared);
await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l)); await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l));
after = await read(at); after = await read(at);
} }
for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) { for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) {
await admit(cap, plan);
ctx.broker.prepareTaskCreate(cap, prepared); ctx.broker.prepareTaskCreate(cap, prepared);
await step(me, 'POST', `/tasks/${at.id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, await step(me, 'POST', `/tasks/${at.id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at,
a => relatedTo(a.task, x.kind, x.other)); a => relatedTo(a.task, x.kind, x.other));
@@ -457,10 +455,36 @@ export function verbs(ctx) {
}] }); }] });
return { task_ref: taskRef, digest: after.digest, updated: after.updated }; return { task_ref: taskRef, digest: after.digest, updated: after.updated };
} }
async function admit(cap, plan) {
// Refresh tracker membership and installation after planning; a cached tree cannot admit a
// reparented/archived board. Reservation already precedes these reads on the recovery path.
const boardProject = ctx.broker.taskCreateTarget(cap, scopeArgs(plan));
if (ctx.descendants) await discover(ctx);
installed(boardProject);
const control = await ctx.sync('GET', `/projects/${boardProject}`);
if (outcome(control) !== 'ok') fail(refusal(control));
if (control.json?.id !== boardProject || control.json?.is_archived !== false) fail('task-project');
const liveView = await inspect(ctx, boardProject);
if (createDigest(liveView) !== createDigest(plan.at.view)) fail('project-not-installed');
// The catalog and session are the last slow check before the PM's authority admission.
const snapshot = await ctx.broker.requirementsFor(cap, scopeArgs(plan));
const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan));
const view = installed(project);
if (createDigest(snapshot) !== createDigest(plan.catalog) || project !== plan.at.project || createDigest(view) !== createDigest(plan.at.view)) fail('requirement-snapshot-refused');
ctx.broker.identity(cap);
}
async function create(cap, me, plan) { async function create(cap, me, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan));
plan.at = { project, view: installed(project) };
plan.catalog = await ctx.broker.requirementsFor(cap, scopeArgs(plan));
const snap = plan.catalog;
plan.evidence = { scope: { project_key: snap.project, requirement_snapshot: snap.id, requirement: plan.requirement },
prd_version: snap.prd_version ?? snap.version,
...(snap.origin === 'product' ? { records_commit: snap.records_commit, spec_commit: snap.spec_commit } : { prd: snap.prd, catalog_sha256: snap.catalog_sha256 }) };
if (plan.op !== undefined) plan.body.description = (plan.body.description ? plan.body.description + '\n\n' : '') + createMarker(plan);
if (plan.op !== undefined) return recoverableCreate(cap, me, plan); if (plan.op !== undefined) return recoverableCreate(cap, me, plan);
await located(plan.others); await located(plan.others);
await admit(cap, plan);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such. // No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
@@ -499,7 +523,7 @@ export function verbs(ctx) {
snapshots: after snapshots: after
? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }] ? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }]
: [], : [],
events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }], events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement, ...plan.evidence } }],
}); });
if (failure) throw failure; if (failure) throw failure;
return { task_ref: taskRef, digest: after.digest, updated: after.updated }; return { task_ref: taskRef, digest: after.digest, updated: after.updated };
@@ -22,25 +22,24 @@ async function fixture(t, edit = {}) {
return { w, pm, args, create, posts }; return { w, pm, args, create, posts };
} }
test('lost response after tracker create is adopted once with original attribution and optional scope', async t => { test('lost response after tracker create is adopted once with original attribution and bound Stack catalogue', async t => {
const { w, args, create, posts } = await fixture(t); const { w, args, create, posts } = await fixture(t);
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
const scope = { project_key: 'demo/product', requirement_snapshot: 'snapshot-v7' }; await assert.rejects(create(), uncertain);
await assert.rejects(create(scope), uncertain);
assert.equal(posts(), 1); assert.equal(posts(), 1);
assert.equal(w.events('task.created').length, 0); assert.equal(w.events('task.created').length, 0);
assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}; scope: demo/product; snapshot: snapshot-v7`); assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}`);
await w.stop(); await w.stop();
await w.restart(); await w.restart();
const [created] = w.events('task.created'); const [created] = w.events('task.created');
assert.equal(created.subject, `vikunja:${w.project}/1`); assert.equal(created.subject, `vikunja:${w.project}/1`);
assert.equal(created.actor_role, 'pm'); assert.equal(created.actor_role, 'pm');
assert.equal(created.actor_run, 'pm-run'); assert.equal(created.actor_run, 'pm-run');
assert.equal(created.body.project_key, 'demo/product'); assert.equal(created.body.scope.project_key, 'stack');
assert.equal(created.body.requirement_snapshot, 'snapshot-v7'); assert.match(created.body.scope.requirement_snapshot, /^builtin-/);
assert.equal(created.body.op, args.op); assert.equal(created.body.op, args.op);
await w.adapter.reconcileCreates('demo'); await w.adapter.reconcileCreates('demo');
const made = await create(scope, resumePm(w)); const made = await create({}, resumePm(w));
assert.equal(made.task_ref, created.subject); assert.equal(made.task_ref, created.subject);
assert.equal(w.events('task.created').length, 1); assert.equal(w.events('task.created').length, 1);
assert.equal(posts(), 1); assert.equal(posts(), 1);
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../../bus/src/runtime.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { prdFixture, git } from '../../project/tests/prd-fixture.mjs';
import { FakeVikunja } from '../src/fake.mjs';
import { tasksAdapter } from '../src/adapter.mjs';
import { SCOPES } from './world.mjs';
// Real product repositories, approval decisions and file-backed bus; only tracker HTTP is fake.
export async function scopedFixture(t) {
const root = tmp(t, 'scoped-'), f = fixture(root, 'demo');
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'tracker.descendants': true } }));
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
let config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env });
const fake = new FakeVikunja(), owner = fake.user('owner'), bots = {};
for (let i = 2; i <= 9; i++) {
const role = ({3:'pm',4:'cto',5:'coder',6:'reviewer',9:'sync'})[i];
const id = fake.user(role ?? `unused${i}`, { owner }); if (role) bots[role] = id;
}
const board = fake.project('Root', owner), child = fake.project('Products', owner, { parent: board });
for (const p of [board, child]) { fake.install(p); for (const [role, bot] of Object.entries(bots)) fake.share(p, bot, role === 'sync' ? 0 : 1); }
const tokens = Object.fromEntries(Object.entries(bots).map(([role, bot]) => [role, fake.token(bot, role === 'sync'
? { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } : role === 'pm' ? SCOPES.pm : SCOPES.worker)]));
const credentials = { status: () => Object.keys(bots).map(role => ({ service: 'vikunja', instance: `demo/${role === 'sync' ? '@sync' : role}`, state: 'valid' })),
use: (instance, service, fn) => fn(tokens[instance.split('/')[1].replace('@','')]), useFor: (s, service, fn) => fn(tokens[s.role]) };
const factory = tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project: board, descendants: true } }, fetch: fake.fetch, autostart: false, log: () => {} });
let bus, adapter, generation = 0;
const caps = {};
const boot = async () => {
config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env });
bus = await startBroker(config); generation++;
adapter = await factory({ broker: bus.broker, credentials, businesses: config.businesses });
await adapter.start('demo'); assert.equal(adapter.status()[0].state, 'ready');
caps.human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.console = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
for (const role of ['pm','cto','coder','reviewer']) {
if (generation > 1) bus.broker.recoverLaunch({ business: 'demo', role, run: `${role}-run-${generation - 1}`, harness: 'pi' });
caps[role] = bus.broker.bindLaunch({ business: 'demo', role, run: `${role}-run-${generation}`, harness: 'pi' });
bus.broker.request(caps[role], { verb: 'role.claim' });
}
};
const close = async () => { await adapter?.close(); await bus?.close(); };
t.after(close); await boot();
const call = (role, verb, args = {}) => bus.broker.request(caps[role], { verb, args });
const instruction = () => call('human', 'message.send', { to: 'pm', body: 'Build a product.' }).request;
const product = async (project, { approve = true, activate = true, tracker_project = child } = {}) => {
const request = instruction(); let state = await call('pm', 'request.open', { request });
state = await call('pm', 'request.propose', { request, revision: state.revision, op: 'plan', source_project: 'stack', project, name: project, tracker_project });
call('human', 'decision.resolve', { id: state.selection.decision, choice: 'autonomous' });
state = await bus.requestLifecycle.resume('demo', request);
const p = await prdFixture(t, { dataRoot: f.dataRoot, business: { id: 'demo', human: 'jason', projects: { [project]: { root: join(f.dataRoot, 'products/demo', project, 'code') } } }, project });
const requirement = 'REQ-POMO-1';
const records_commit = p.save(`requirements/${requirement}.json`, JSON.stringify({ recordVersion: 1, id: requirement, title: 'Timer', text: `${project} timer behavior.`, status: 'draft' }), 'pm.pm');
const submission = await call('pm', 'prd.submit', { request, revision: state.revision, op: 'prd', prd_version: '1.0', records_commit, prd_path: p.args.prd_path, requirement_ids: [requirement] });
if (approve) call('cto', 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
const snapshot = approve && activate ? await bus.requirementCatalog.activate({ request, decision: submission.decision.id }) : null;
return { ...p, request, submission, snapshot, args: { title: project, request, requirement, project_key: project, requirement_snapshot: snapshot?.id ?? 'unapproved', op: 'create-' + project } };
};
const create = args => bus.broker.requestTask(caps.pm, { verb: 'task.create', args }, adapter.handle);
const posts = () => fake.requests.filter(r => r.method === 'POST' && /^\/projects\/\d+\/tasks$/.test(r.path)).length;
return { f, get config() { return config; }, fake, board, child, caps, call, product, create, instruction, posts,
get bus() { return bus; }, get adapter() { return adapter; },
restart: async () => { await close(); await boot(); } };
}
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { scopedFixture } from './scoped-fixture.mjs';
import { world } from './world.mjs';
import { loadRequirements } from '../src/requirements.mjs';
const scope = p => ({ project_key: p.args.project_key, requirement_snapshot: p.snapshot.id });
const created = (f, task) => f.call('pm', 'trail', { subject: task.task_ref }).find(e => e.kind === 'task.created').body;
test('two products on one board share an ID but use exact approved snapshots in discovery, create, filtering and trail', async t => {
const f = await scopedFixture(t), a = await f.product('timer'), b = await f.product('clock');
const tasks = [];
for (const p of [a,b]) {
assert.deepEqual(await f.call('pm', 'requirements', scope(p)), p.snapshot);
assert.deepEqual(await f.bus.broker.requirementsFor(f.caps.pm, { ...scope(p), requirement: p.args.requirement }), p.snapshot);
const task = await f.create(p.args); tasks.push(task);
const body = created(f, task);
assert.deepEqual(body.scope, { ...scope(p), requirement: p.args.requirement });
for (const k of ['prd_version','records_commit','spec_commit']) assert.equal(body[k], p.snapshot[k]);
assert.equal(body.op, p.args.op);
assert.equal(f.fake.task(Number(task.task_ref.split('/')[1])).project, f.child);
const filtered = f.call('pm', 'tasks', { project_key: p.args.project_key, requirement: p.args.requirement });
assert.deepEqual(filtered.tasks.map(t => t.task_ref), [task.task_ref]);
assert.equal(filtered.tasks[0].requirement_snapshot, p.snapshot.id);
}
assert.notEqual(a.snapshot.id, b.snapshot.id);
assert.notEqual(a.snapshot.records_commit, b.snapshot.records_commit);
assert.notEqual(a.snapshot.requirements[0].text, b.snapshot.requirements[0].text); assert.equal(f.posts(), 2);
await f.restart();
assert.deepEqual(await f.call('pm', 'requirements', scope(a)), a.snapshot);
assert.equal((await f.create(a.args)).task_ref, tasks[0].task_ref); assert.equal(f.posts(), 2);
});
test('product calls require all scope fields and cannot borrow another project, board, draft or revoked snapshot', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), other = await f.product('clock'), draft = await f.product('draft', { approve: false });
for (const field of ['project_key','requirement_snapshot','op']) {
const args = { ...p.args }; delete args[field]; await assert.rejects(f.create(args), { code: 'task-scope-required' });
}
await assert.rejects(f.create({ ...p.args, project_key: 'clock' }), { code: 'request-project-refused' });
await assert.rejects(f.create({ ...p.args, requirement_snapshot: other.snapshot.id }));
await assert.rejects(f.create({ ...p.args, project: f.board }), { code: 'request-tracker-refused' });
await assert.rejects(f.create(draft.args));
const state = await f.call('pm', 'request.get', { request: p.request });
await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Replace', op: 'revoke' });
await assert.rejects(f.create(p.args)); assert.equal(f.posts(), 0);
assert.equal(state.phase, 'planning');
});
test('selection, discovery and ambiguous requests cannot evade product approval by omitting scope', async t => {
const f = await scopedFixture(t);
const request = f.instruction(); await f.call('pm', 'request.open', { request });
await assert.rejects(f.create({ title: 'selection', request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' });
const draft = await f.product('draft', { approve: false });
f.call('cto', 'decision.resolve', { id: draft.submission.decision.id, choice: 'no' });
await f.bus.requestLifecycle.resume('demo', draft.request);
await assert.rejects(f.create({ title: 'discovery', request: draft.request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' });
await assert.rejects(f.create({ title: 'ambiguous', request: f.instruction(), requirement: 'REQ-TASK-1', project_key: 'draft', requirement_snapshot: 'unknown', op: 'ambiguous' }));
assert.equal(f.posts(), 0);
});
test('admission repeats the catalog and session/authority checks after planning and relation reads', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let calls = 0;
f.bus.broker.requirementsFor = async (...args) => {
calls++; const snapshot = await original(...args);
if (calls === 1) await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Revoke during planning', op: 'race' });
return snapshot;
};
await assert.rejects(f.create(p.args)); assert.ok(calls >= 2); assert.equal(f.posts(), 0);
});
test('legacy response is unchanged but creation requires an explicitly bound validated Stack project', async t => {
const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm');
assert.deepEqual(w.broker.request(pm, { verb: 'requirements' }), loadRequirements());
const result = await w.call(pm, 'task.create', { title: 'legacy', request: w.instruction(), requirement: 'REQ-TASK-1' });
const body = w.events('task.created')[0].body;
assert.equal(body.scope.project_key, 'stack'); assert.equal(body.prd_version, loadRequirements().version);
assert.match(body.catalog_sha256, /^[a-f0-9]{64}$/); assert.equal(body.approval, undefined);
assert.ok(result.task_ref);
const f = await scopedFixture(t);
await assert.rejects(f.create({ title: 'wrong Stack identity', request: f.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' });
});
test('a product removed from the installed tree after planning cannot create on the old board', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false;
f.bus.broker.requirementsFor = async (...args) => {
const snapshot = await original(...args);
if (!planned) { planned = true; f.fake.reparent(f.child, 0); }
return snapshot;
};
await assert.rejects(f.create(p.args), { code: 'task-project' }); assert.equal(f.posts(), 0);
});
for (const change of ['authority','session']) test(`create admission rechecks ${change} after planning`, async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false;
f.bus.broker.requirementsFor = async (...args) => {
const snapshot = await original(...args);
if (!planned) {
planned = true;
if (change === 'session') f.call('pm', 'role.release');
else {
const b = structuredClone(f.config.businesses.demo);
b.projects.timer = { root: p.context.business.projects.timer.root };
b.roles.pm.authority.withinRole = b.roles.pm.authority.withinRole.filter(a => a !== 'task.create');
f.bus.broker.reloadBusiness('demo', b);
}
}
return snapshot;
};
await assert.rejects(f.create(p.args), { code: change === 'authority' ? 'decision-required' : 'not-holder' });
assert.equal(f.posts(), 0);
});
test('bootConfig binds only the proven Stack checkout and a scoped caller never borrows its builtin snapshot', async t => {
const { fileURLToPath } = await import('node:url');
const { writeFileSync } = await import('node:fs');
const { fixture, tmp } = await import('../../cli/tests/helpers.mjs');
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const f = fixture(tmp(t, 'builtin-'), 'demo');
f.doc.projects.stack.root = fileURLToPath(new URL('../../../', import.meta.url));
writeFileSync(f.env.MOSAIC_CONFIG.replace('/config.json','/businesses/demo.json'), JSON.stringify(f.doc));
const config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env });
assert.deepEqual(config.builtinBinding, { business: 'demo', project: 'stack', tracker_project: 19 });
const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm');
const builtin = await w.broker.requirementsFor(pm, { request: w.instruction(), requirement: 'REQ-TASK-1' });
await assert.rejects(w.broker.requirementsFor(pm, { project_key: 'stack', requirement_snapshot: builtin.id }), { code: 'requirement-snapshot-required' });
const unbound = await world(t, { builtinBinding: false }); await unbound.adapter.start('demo');
await assert.rejects(unbound.call(unbound.agent('pm'), 'task.create', { title: 'unbound', request: unbound.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' });
});
test('lost product create response survives Store reopen with exact scope and original attribution', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
f.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
await assert.rejects(f.create(p.args), { code: 'task-create-uncertain' }); assert.equal(f.posts(), 1);
assert.ok(f.fake.task(1).description.endsWith(`Mosaic operation: ${p.args.op}; request: ${p.request}; scope: timer; snapshot: ${p.snapshot.id}`));
await f.restart();
const made = await f.create(p.args);
const trail = f.call('pm', 'trail', { subject: made.task_ref }).filter(e => e.kind === 'task.created');
assert.equal(trail.length, 1); assert.equal(trail[0].actor_run, 'pm-run-1');
assert.deepEqual(trail[0].body.scope, { ...scope(p), requirement: p.args.requirement });
assert.equal(trail[0].body.records_commit, p.snapshot.records_commit); assert.equal(f.posts(), 1);
});
test('a product derived from Stack history cannot claim the builtin catalogue as its own', async t => {
const { tmp } = await import('../../cli/tests/helpers.mjs');
const { git } = await import('../../project/tests/prd-fixture.mjs');
const { fileURLToPath } = await import('node:url');
const { writeFileSync } = await import('node:fs');
const { stackBinding } = await import('../src/builtin.mjs');
const root = tmp(t, 'derived-'), clone = root + '/code';
git(root, 'clone', '-q', '--shared', '--no-checkout', fileURLToPath(new URL('../../../', import.meta.url)), clone);
git(clone, 'checkout', 'HEAD', '--', '.mosaic/project.json', 'packages/tasks/requirements.json', 'docs/prd/mosaic-stack.md');
writeFileSync(clone + '/.mosaic/project.json', JSON.stringify({ projectVersion: 1, id: 'timer', vars: { 'tracker.project': 19 } }));
git(clone, 'add', '.mosaic/project.json'); git(clone, 'commit', '-qm', 'Product identity');
assert.equal(stackBinding({ business: 'demo', project: 'timer', root: clone, tracker_project: 19 }), null);
});
+25 -18
View File
@@ -29,7 +29,7 @@ test('task.create needs a recorded human request and a requirement id', async (t
assert.equal(r.task_ref, `vikunja:${w.project}/1`); assert.equal(r.task_ref, `vikunja:${w.project}/1`);
const created = w.events('task.created'); const created = w.events('task.created');
assert.equal(created.length, 1); assert.equal(created.length, 1);
assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-TASK-1' }); assert.deepEqual({ request: created[0].body.request, requirement: created[0].body.requirement }, { request: args.request, requirement: 'REQ-TASK-1' });
assert.equal(created[0].actor_role, 'pm'); assert.equal(created[0].actor_role, 'pm');
const [s] = w.snapshots(r.task_ref); const [s] = w.snapshots(r.task_ref);
assert.equal(s.source, 'self'); assert.equal(s.source, 'self');
@@ -78,7 +78,7 @@ test("task.create cites the request a received message carries, not the message'
const args = { title: 'the thing', requirement: 'REQ-TASK-1' }; const args = { title: 'the thing', requirement: 'REQ-TASK-1' };
await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found')); await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found'));
const r = await w.call(pm, 'task.create', { ...args, request: m.request }); const r = await w.call(pm, 'task.create', { ...args, request: m.request });
assert.deepEqual(w.events('task.created').map((e) => [e.subject, e.body]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]); assert.deepEqual(w.events('task.created').map((e) => [e.subject, { request: e.body.request, requirement: e.body.requirement }]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]);
}); });
test('only labels named in the business file can be written', async (t) => { test('only labels named in the business file can be written', async (t) => {
const w = await ready(t); const w = await ready(t);
@@ -239,13 +239,13 @@ test('task.close needs a verdict; after it every verb refuses with task-done', a
}); });
test('a task with pushed work closes only citing an approve verdict for its latest push, and a refusal writes nothing', async (t) => { test('a task with pushed work closes only citing an approve verdict for its latest push, and a refusal writes nothing', async (t) => {
// Row 93 (#1587): the verb hands its verdict to the broker's check. // Row 93 (#1587): the verb hands its verdict to the broker's check.
const w = await ready(t, { business: (b) => (b.projects = { stack: { root: '/nonexistent' } }) }); const w = await ready(t);
const pm = w.agent('pm'); const pm = w.agent('pm');
const coder = w.agent('coder'); const coder = w.agent('coder');
const reviewer = w.agent('reviewer'); const reviewer = w.agent('reviewer');
const { task_ref } = await task(w); const { task_ref } = await task(w);
const push = (commit, previous) => { const push = (commit, previous) => {
w.broker.checkPush(coder, task_ref); w.broker.checkPush(coder, task_ref, 'stack');
w.broker.recordPush(coder, { task_ref, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-1', commit, previous }); w.broker.recordPush(coder, { task_ref, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-1', commit, previous });
}; };
const verdict = (commit, v) => { const verdict = (commit, v) => {
@@ -479,11 +479,17 @@ const TREE = {
tracker: { descendants: true }, tracker: { descendants: true },
scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } }, scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } },
}; };
const tree = async (t, edit = {}) => { const tree = async (t, edit = {}, boundChild = false) => {
const w = await world(t, { ...TREE, ...edit }); let child, board, grandchild;
const child = w.fake.project('Child', w.owner, { parent: w.project }); const w = await world(t, { ...TREE, ...edit,
const board = w.fake.install(child); ...(boundChild ? { builtinBinding: () => child } : {}),
const grandchild = w.fake.project('Grandchild', w.owner, { parent: child }); business: (b, { fake, owner, project }) => {
edit.business?.(b, { fake, owner, project });
child = fake.project('Child', owner, { parent: project });
board = fake.install(child);
grandchild = fake.project('Grandchild', owner, { parent: child });
},
});
const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id; const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id;
await w.adapter.start('demo'); await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
@@ -491,11 +497,12 @@ const tree = async (t, edit = {}) => {
}; };
const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length; const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length;
test('every verb acts in an installed child, on the child\'s own board', async (t) => { test('every verb acts in an installed child, on the child\'s own board', async (t) => {
const { w, child, board } = await tree(t); const { w, child, board } = await tree(t, {}, true);
const pm = w.agent('pm'); const pm = w.agent('pm');
const coder = w.agent('coder'); const coder = w.agent('coder');
const cto = w.agent('cto'); const cto = w.agent('cto');
const root = await task(w); // Stack is explicitly bound to the installed child for this fixture. The root task belongs to the tracker UI.
const root = { task_ref: `vikunja:${w.project}/${(await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'root' })).json.id}` };
assert.equal(root.task_ref, `vikunja:${w.project}/2`); assert.equal(root.task_ref, `vikunja:${w.project}/2`);
const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] }); const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] });
const task_ref = `vikunja:${child}/3`; const task_ref = `vikunja:${child}/3`;
@@ -528,8 +535,8 @@ test('every verb acts in an installed child, on the child\'s own board', async (
assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child); assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child);
assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo);
assert.equal(w.events('task.created').at(-1).subject, task_ref); assert.equal(w.events('task.created').at(-1).subject, task_ref);
// An explicit root project is the default spelled out. // Unscoped Stack calls cannot select another board by number.
assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`); await assert.rejects(task(w, { project: w.project }), refusedWith('request-tracker-refused'));
await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]); assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]);
@@ -608,12 +615,12 @@ test('a related task is read before authority: in another project than its ref n
w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }), w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }),
refusedWith('task-moved'), refusedWith('task-moved'),
); );
await assert.rejects(task(w, { project: child, relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved')); await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved'));
await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found')); await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found'));
assert.equal(writes(w), before); assert.equal(writes(w), before);
assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1); assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1);
// The same relation with the right ref goes through. // The same relation with the right ref goes through.
const other = await task(w, { project: child }); const other = { task_ref: `vikunja:${child}/${(await w.ui('POST', `/projects/${child}/tasks`, { title: 'child UI task' })).json.id}` };
await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } }); await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } });
assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]); assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]);
}); });
@@ -633,7 +640,7 @@ test('a task moved to another project of the tree refuses its old ref with task-
// The child versions of the re-read and fallback paths the root's tests above cover (Dewey's // The child versions of the re-read and fallback paths the root's tests above cover (Dewey's
// and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view. // and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view.
test('a verb and a create in the child record the final read of the child\'s board', async (t) => { test('a verb and a create in the child record the final read of the child\'s board', async (t) => {
const { w, child } = await tree(t); const { w, child } = await tree(t, {}, true);
const pm = w.agent('pm'); const pm = w.agent('pm');
const made = await task(w, { project: child }); const made = await task(w, { project: child });
assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null);
@@ -643,7 +650,7 @@ test('a verb and a create in the child record the final read of the child\'s boa
test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => { test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => {
// The grandchild's task is 1, so the create makes task 2. // The grandchild's task is 1, so the create makes task 2.
const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2); const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2);
const { w, child, board } = await tree(t, f.edit); const { w, child, board } = await tree(t, f.edit, true);
w.agent('pm'); w.agent('pm');
f.arm(); f.arm();
const r = await task(w, { project: child }); const r = await task(w, { project: child });
@@ -659,7 +666,7 @@ test('a create in the child whose final read fails records the child\'s todo buc
assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]); assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]);
}); });
test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => { test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => {
const { w, child, board } = await tree(t); const { w, child, board } = await tree(t, {}, true);
const pm = w.agent('pm'); const pm = w.agent('pm');
const coder = w.agent('coder'); const coder = w.agent('coder');
const { task_ref } = await task(w, { project: child }); const { task_ref } = await task(w, { project: child });
+7 -2
View File
@@ -1,7 +1,9 @@
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path'; import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs'; import { Store } from '../../bus/src/store.mjs';
import { createRequirementsHost } from '../src/builtin.mjs';
import { Broker } from '../../bus/src/broker.mjs'; import { Broker } from '../../bus/src/broker.mjs';
import { Credentials } from '../../bus/src/credentials.mjs'; import { Credentials } from '../../bus/src/credentials.mjs';
import { FakeVikunja } from '../src/fake.mjs'; import { FakeVikunja } from '../src/fake.mjs';
@@ -93,6 +95,7 @@ export async function world(t, edit = {}) {
human: 'jason', human: 'jason',
arbiters: { technical: 'cto', delivery: 'pm' }, arbiters: { technical: 'cto', delivery: 'pm' },
roles, roles,
projects: { stack: { root: fileURLToPath(new URL('../../../', import.meta.url)) } },
tracker: { tracker: {
sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } }, sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } },
labels: { 'slice-1': label, ...edit.labels }, labels: { 'slice-1': label, ...edit.labels },
@@ -104,8 +107,10 @@ export async function world(t, edit = {}) {
for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials; for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials;
if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials; if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials;
const credentials = new Credentials({ references, env, dataRoot: root }); const credentials = new Credentials({ references, env, dataRoot: root });
const builtinBinding = edit.builtinBinding === false ? null : { business: 'demo', project: 'stack',
tracker_project: typeof edit.builtinBinding === 'function' ? edit.builtinBinding() : project };
let store = new Store(root); let store = new Store(root);
let broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); let broker = new Broker({ store, businesses, requirementsHost: createRequirementsHost({ store, businesses, builtinBinding }), secretCheck: (v) => credentials.assertClean(v) });
const log = []; const log = [];
const factory = tasksAdapter({ const factory = tasksAdapter({
trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } }, trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } },
@@ -151,7 +156,7 @@ export async function world(t, edit = {}) {
}, },
async restart() { async restart() {
store = new Store(root); store = new Store(root);
broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); broker = new Broker({ store, businesses, requirementsHost: createRequirementsHost({ store, businesses, builtinBinding }), secretCheck: (v) => credentials.assertClean(v) });
adapter = await factory({ broker, credentials, businesses }); adapter = await factory({ broker, credentials, businesses });
await adapter.start('demo'); await adapter.start('demo');
if (adapter.status()[0].state !== 'ready') throw Error(adapter.status()[0].refused); if (adapter.status()[0].state !== 'ready') throw Error(adapter.status()[0].refused);