feat(tasks): bind requirement and git admission to product scope (#1839)
This commit is contained in:
1 parent
37d44f36e0
commit
b5a58b48b4
21 files changed
+640
-95
No files matched your search
@@ -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-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-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.
|
||||
@@ -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 | 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-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.
|
||||
@@ -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
|
||||
`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.
|
||||
| `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. |
|
||||
| `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}`. |
|
||||
@@ -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
|
||||
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
|
||||
first `task.created` for the same Vikunja task id in the business, so a task moved to another
|
||||
project keeps it; null for a task the stack didn't create.
|
||||
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
|
||||
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
|
||||
|
||||
+106
-21
@@ -95,7 +95,7 @@ export const REPLIES = 50;
|
||||
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
|
||||
export const STRANDED = 50;
|
||||
// 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_MAX = 200;
|
||||
// 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 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 = []) {
|
||||
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => !(k in x)))
|
||||
fail('invalid-request');
|
||||
@@ -454,6 +455,10 @@ export class Broker {
|
||||
#secretCheck;
|
||||
#credentialStatus;
|
||||
#requirements;
|
||||
#catalog;
|
||||
#requestJournal;
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
@@ -516,7 +521,7 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// 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');
|
||||
// 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');
|
||||
@@ -569,6 +574,12 @@ export class Broker {
|
||||
this.#roleFile = roleFile;
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
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 [
|
||||
'events',
|
||||
'role_claims',
|
||||
@@ -598,6 +609,7 @@ export class Broker {
|
||||
if (typeof requestLifecycle !== 'function') throw new TypeError('requestLifecycle must be a host factory');
|
||||
this.#requests = requestLifecycle({
|
||||
dataRoot,
|
||||
builtinBinding: builtinFor,
|
||||
origin: (identity, request) => this.#requestOrigin(identity, request),
|
||||
authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation),
|
||||
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');
|
||||
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) {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
keys(args, ['project_key','requirement_snapshot','requirement'], ['project_key']);
|
||||
this.#agent(s); this.#processingGate(s, 'requirements');
|
||||
if (!this.#business(s.business).projects?.[args.project_key]) fail('request-project-refused');
|
||||
if (!this.#requests?.catalog) fail('requirement-snapshot-required');
|
||||
const result = await this.#requests.catalog.resolve({ business: s.business, project: args.project_key,
|
||||
...(args.requirement_snapshot === undefined ? {} : { snapshot: args.requirement_snapshot }) });
|
||||
this.#agent(s); this.#processingGate(s, 'requirements');
|
||||
if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement');
|
||||
return result;
|
||||
} catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); }
|
||||
try { return await this.#requirementsFor(s, args); }
|
||||
catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); }
|
||||
}
|
||||
async #requirementsFor(s, args) {
|
||||
keys(args, ['project_key','requirement_snapshot','requirement','request','project','op']);
|
||||
this.#agent(s); this.#processingGate(s, 'requirements');
|
||||
if (args.request !== undefined) this.#createTarget(s, args);
|
||||
const project = args.project_key ?? (args.request !== undefined ? this.#builtinBinding?.project : undefined);
|
||||
if (!projectKey(project) || !this.#business(s.business).projects?.[project] || this.#business(s.business).projects[project].kind === 'library') fail('request-project-refused');
|
||||
if (args.requirement_snapshot !== undefined && !id(args.requirement_snapshot)) fail('invalid-request');
|
||||
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
|
||||
// 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.
|
||||
#taskRows(business) {
|
||||
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(
|
||||
"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,
|
||||
))
|
||||
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
|
||||
.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business)
|
||||
.map((r) => {
|
||||
const fields = JSON.parse(r.fields);
|
||||
const priority = fields.priority ?? null;
|
||||
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
|
||||
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
|
||||
@@ -4311,6 +4367,8 @@ export class Broker {
|
||||
('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) ||
|
||||
('label' in a && !positive(a.label)) ||
|
||||
('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)) ||
|
||||
('after' in a && !taskRef(a.after))
|
||||
)
|
||||
@@ -4322,7 +4380,7 @@ export class Broker {
|
||||
);
|
||||
const matched = rows
|
||||
.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,
|
||||
title: f.title ?? null,
|
||||
done: Boolean(f.done),
|
||||
@@ -4331,6 +4389,7 @@ export class Broker {
|
||||
labels: f.labels ?? [],
|
||||
priority,
|
||||
requirement,
|
||||
...(project_key === undefined ? {} : { project_key, requirement_snapshot }),
|
||||
changed_at: at,
|
||||
}))
|
||||
.filter(
|
||||
@@ -4338,7 +4397,9 @@ export class Broker {
|
||||
(!('done' in a) || t.done === a.done) &&
|
||||
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
|
||||
(!('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 limit = a.limit ?? TASK_PAGE;
|
||||
@@ -4469,16 +4530,26 @@ export class Broker {
|
||||
business,
|
||||
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
|
||||
// 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();
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
if (!taskRef(task_ref)) fail('invalid-request');
|
||||
this.#taskProject(s.business, task_ref, project);
|
||||
return this.#checkAuthority(s, 'git.push.working', { target: task_ref });
|
||||
});
|
||||
}
|
||||
@@ -4499,6 +4570,7 @@ export class Broker {
|
||||
string(push.branch, 200);
|
||||
string(push.ref, 200);
|
||||
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 });
|
||||
return { task_ref, commit };
|
||||
});
|
||||
@@ -4621,6 +4693,7 @@ export class Broker {
|
||||
string(args.branch, 200);
|
||||
this.#agent(s);
|
||||
const { task_ref, project, branch } = args;
|
||||
this.#taskProject(s.business, task_ref, project);
|
||||
const decision = args.decision ?? null;
|
||||
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
|
||||
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
|
||||
// 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) {
|
||||
this.#taskProject(s.business, task_ref, project);
|
||||
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
|
||||
const b = this.#business(s.business);
|
||||
const role = b.roles?.[s.role];
|
||||
@@ -4928,6 +5002,7 @@ export class Broker {
|
||||
);
|
||||
const body = r ? JSON.parse(r.body) : null;
|
||||
if (!body || body.to !== s.role) fail('request-not-found');
|
||||
this.#taskProject(s.business, task, body.project);
|
||||
return { ...body, from: r.actor_role };
|
||||
}
|
||||
// Trusted adapters only. No agent socket route reaches this method.
|
||||
@@ -5007,6 +5082,7 @@ export class Broker {
|
||||
this.#snapshot(s, kept);
|
||||
this.#event(s, 'task.created', {
|
||||
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.requirement_snapshot === undefined ? {} : { requirement_snapshot: p.requirement_snapshot }),
|
||||
}, task_ref);
|
||||
@@ -5197,6 +5273,15 @@ export class Broker {
|
||||
? 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); });
|
||||
}
|
||||
// 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 (s.reader) fail('read-only');
|
||||
if (s.tester) fail('tester-refused');
|
||||
|
||||
@@ -46,7 +46,7 @@ function launchOp(m) {
|
||||
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
|
||||
if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record);
|
||||
// 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 === '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.
|
||||
|
||||
@@ -19,10 +19,11 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
|
||||
exact(args, ['business','project'], ['snapshot']);
|
||||
if (!key(args.business) || !key(args.project) || (args.snapshot !== undefined && !id(args.snapshot))) refuse('invalid-request');
|
||||
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 snapshot = args.snapshot ?? [...events].reverse().find(e => e.kind === 'activated')?.snapshot;
|
||||
if (!snapshot) {
|
||||
const builtin = await builtinBinding?.({ business, project });
|
||||
if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin;
|
||||
refuse('requirement-snapshot-required');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
|
||||
import { createRequirementCatalog } from './requirement-catalog.mjs';
|
||||
import { readPrdMaterial } from '../../project/src/prd.mjs';
|
||||
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 host's new-project preparation reads each business's tracker root board from this one.
|
||||
trackers = null,
|
||||
builtinBinding = null,
|
||||
}) {
|
||||
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)))
|
||||
@@ -580,7 +582,9 @@ export async function startBroker({
|
||||
}
|
||||
return no(declared ? 'project-pending' : 'project-unknown');
|
||||
}
|
||||
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
|
||||
broker = new Broker({
|
||||
requirementsHost,
|
||||
requestLifecycle: services => {
|
||||
const readCandidate = (candidate, business) => {
|
||||
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 },
|
||||
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,
|
||||
// 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: {
|
||||
@@ -849,7 +853,7 @@ export async function startBroker({
|
||||
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
|
||||
// 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);
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// does), the business file, and the boot message for the broker process.
|
||||
// Everything here reads and refuses; nothing writes.
|
||||
|
||||
import { stackBinding } from "../../tasks/src/builtin.mjs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -81,13 +82,13 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
return null;
|
||||
}
|
||||
const v = named[0].vars;
|
||||
return {
|
||||
return { tracker: {
|
||||
baseUrl: v["tracker.baseUrl"],
|
||||
project: v["tracker.project"],
|
||||
pollSeconds: v["tracker.pollSeconds"],
|
||||
reconcileMinutes: v["tracker.reconcileMinutes"],
|
||||
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
|
||||
@@ -142,7 +143,10 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
readers: [b.id],
|
||||
};
|
||||
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);
|
||||
if (access) config.access = access;
|
||||
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
|
||||
|
||||
@@ -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}`);
|
||||
// The runner waits for "send"; bytes before it break the protocol.
|
||||
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`);
|
||||
await readBody(socket, file, size, deadlineOf(size), bodyIdle);
|
||||
const result = await push({
|
||||
|
||||
@@ -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(", ")}`);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const w = await setup(t, { bundleMax: 64 });
|
||||
gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat");
|
||||
|
||||
@@ -48,7 +48,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori
|
||||
const TASK_TOOLS = {
|
||||
"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"),
|
||||
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"),
|
||||
due_date: DUE,
|
||||
priority: PRIORITY,
|
||||
@@ -56,6 +56,9 @@ const TASK_TOOLS = {
|
||||
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"),
|
||||
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,
|
||||
}, ["title", "request", "requirement"]],
|
||||
"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"),
|
||||
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]*$" }),
|
||||
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` },
|
||||
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"]));
|
||||
// Row 90 (#1584): the ids task_create takes, for the sessions that hold it.
|
||||
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
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
|
||||
@@ -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 list = tools.find((x) => x.name === "list_requirements");
|
||||
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, /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"));
|
||||
const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"]));
|
||||
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");
|
||||
assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]);
|
||||
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.equal(p.properties.done.type, "boolean");
|
||||
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");
|
||||
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
@@ -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
|
||||
version is a new list in a reviewed commit. `loadRequirements()`
|
||||
(`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
|
||||
loads it for its `requirements` read. Neither starts on a bad file.
|
||||
returns it frozen; the broker loads it at startup and preserves the unscoped
|
||||
`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
|
||||
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
|
||||
business (`request-not-found` otherwise), and a `requirement` from
|
||||
`requirements.json`, like `REQ-TASK-3`. An id in the right form that
|
||||
the list doesn't hold refuses `unknown-requirement`. The `request` is
|
||||
its approved catalogue snapshot, like `REQ-TASK-3`. An id in the right form
|
||||
that the snapshot does not hold refuses `unknown-requirement`. The `request` is
|
||||
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
|
||||
task in, the root when left out. Any other project than an installed
|
||||
message's own id. Every intake request, including selection and discovery,
|
||||
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
|
||||
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.
|
||||
@@ -238,13 +258,12 @@ so a poll never reads between two writes of a verb.
|
||||
|
||||
## Recoverable creates (lifecycle Task 5a)
|
||||
|
||||
A lifecycle caller supplies a stable `op` with `task.create` and reuses it for
|
||||
all retries of that exact request and body. Existing calls without the new
|
||||
fields keep their previous behavior. Optional `project_key` and
|
||||
`requirement_snapshot` are opaque strings carried into evidence; Task 5b will
|
||||
validate their catalogue binding. If either is supplied without `op`, the
|
||||
adapter generates a key, recorded in `task.created` evidence. Supply your own
|
||||
key to retry safely after a lost response.
|
||||
A product caller supplies `project_key`, `requirement_snapshot` and a stable
|
||||
`op` with `task.create`, reusing all three for retries of that exact request and
|
||||
body. The adapter validates the approved snapshot and request binding; it never
|
||||
generates an omitted product operation key. Unscoped legacy calls retain the
|
||||
existing response/body behavior through the explicit Stack binding and can opt
|
||||
into recovery by supplying `op`.
|
||||
|
||||
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 |
|
||||
| `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-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 |
|
||||
| `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 |
|
||||
|
||||
@@ -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') }) };
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { reconcileCreate } from './create-recovery.mjs';
|
||||
import { BusError, createMarker, createDigest } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal } from './vikunja.mjs';
|
||||
import { ref, parseRef, checkTask, taskFields, digest } from './digest.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 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
|
||||
@@ -67,8 +66,9 @@ const sorted = (a) => [...a].sort((x, y) => x - y);
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
export function verbs(ctx) {
|
||||
const fail = (code) => refuse(code);
|
||||
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start.
|
||||
const requirements = new Set(loadRequirements().requirements.map((r) => r.id));
|
||||
const scopeArgs = plan => ({ request: plan.request, requirement: plan.requirement,
|
||||
...(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) => {
|
||||
// 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');
|
||||
@@ -203,12 +203,13 @@ export function verbs(ctx) {
|
||||
switch (verb) {
|
||||
case 'task.create': {
|
||||
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
|
||||
// it the task goes to the root.
|
||||
const project = args.project === undefined ? ctx.project : args.project;
|
||||
if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request');
|
||||
if (!ctx.tree.get(project)) fail('project-not-installed');
|
||||
plan.at = { project, view: ctx.tree.get(project) };
|
||||
// The optional numeric project is only a board assertion. The broker resolves the target
|
||||
// from the confirmed request (or the explicitly bound legacy Stack catalogue).
|
||||
if (args.project !== undefined) {
|
||||
if (!Number.isSafeInteger(args.project) || args.project < 1) fail('invalid-request');
|
||||
if (!ctx.tree.get(args.project)) fail('project-not-installed');
|
||||
}
|
||||
plan.project = args.project;
|
||||
const body = { title: text(args.title, 250, 1) };
|
||||
if (!body.title.trim()) fail('invalid-request');
|
||||
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.requirement = text(args.requirement, 64);
|
||||
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'])
|
||||
if (args[field] !== undefined) {
|
||||
plan[field] = text(args[field], 256, 1);
|
||||
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;
|
||||
}
|
||||
case 'task.assign':
|
||||
@@ -406,6 +400,7 @@ export function verbs(ctx) {
|
||||
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,
|
||||
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.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }),
|
||||
};
|
||||
@@ -413,6 +408,7 @@ export function verbs(ctx) {
|
||||
let taskRef;
|
||||
if (fresh) {
|
||||
await located(plan.others);
|
||||
await admit(cap, plan);
|
||||
ctx.broker.prepareTaskCreate(cap, prepared);
|
||||
const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body });
|
||||
if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) {
|
||||
@@ -441,11 +437,13 @@ export function verbs(ctx) {
|
||||
if (!after.task.done) {
|
||||
await located(plan.others);
|
||||
for (const l of plan.labels) if (!after.fields.labels.includes(l)) {
|
||||
await admit(cap, plan);
|
||||
ctx.broker.prepareTaskCreate(cap, prepared);
|
||||
await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l));
|
||||
after = await read(at);
|
||||
}
|
||||
for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) {
|
||||
await admit(cap, plan);
|
||||
ctx.broker.prepareTaskCreate(cap, prepared);
|
||||
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));
|
||||
@@ -457,10 +455,36 @@ export function verbs(ctx) {
|
||||
}] });
|
||||
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) {
|
||||
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);
|
||||
await located(plan.others);
|
||||
await admit(cap, plan);
|
||||
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.
|
||||
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
|
||||
? [{ 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;
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
|
||||
const scope = { project_key: 'demo/product', requirement_snapshot: 'snapshot-v7' };
|
||||
await assert.rejects(create(scope), uncertain);
|
||||
await assert.rejects(create(), uncertain);
|
||||
assert.equal(posts(), 1);
|
||||
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.restart();
|
||||
const [created] = w.events('task.created');
|
||||
assert.equal(created.subject, `vikunja:${w.project}/1`);
|
||||
assert.equal(created.actor_role, 'pm');
|
||||
assert.equal(created.actor_run, 'pm-run');
|
||||
assert.equal(created.body.project_key, 'demo/product');
|
||||
assert.equal(created.body.requirement_snapshot, 'snapshot-v7');
|
||||
assert.equal(created.body.scope.project_key, 'stack');
|
||||
assert.match(created.body.scope.requirement_snapshot, /^builtin-/);
|
||||
assert.equal(created.body.op, args.op);
|
||||
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(w.events('task.created').length, 1);
|
||||
assert.equal(posts(), 1);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
const created = w.events('task.created');
|
||||
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');
|
||||
const [s] = w.snapshots(r.task_ref);
|
||||
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' };
|
||||
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 });
|
||||
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) => {
|
||||
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) => {
|
||||
// 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 coder = w.agent('coder');
|
||||
const reviewer = w.agent('reviewer');
|
||||
const { task_ref } = await task(w);
|
||||
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 });
|
||||
};
|
||||
const verdict = (commit, v) => {
|
||||
@@ -479,11 +479,17 @@ const TREE = {
|
||||
tracker: { descendants: true },
|
||||
scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } },
|
||||
};
|
||||
const tree = async (t, edit = {}) => {
|
||||
const w = await world(t, { ...TREE, ...edit });
|
||||
const child = w.fake.project('Child', w.owner, { parent: w.project });
|
||||
const board = w.fake.install(child);
|
||||
const grandchild = w.fake.project('Grandchild', w.owner, { parent: child });
|
||||
const tree = async (t, edit = {}, boundChild = false) => {
|
||||
let child, board, grandchild;
|
||||
const w = await world(t, { ...TREE, ...edit,
|
||||
...(boundChild ? { builtinBinding: () => 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;
|
||||
await w.adapter.start('demo');
|
||||
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;
|
||||
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 coder = w.agent('coder');
|
||||
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`);
|
||||
const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] });
|
||||
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.bucket, board.buckets.todo);
|
||||
assert.equal(w.events('task.created').at(-1).subject, task_ref);
|
||||
// An explicit root project is the default spelled out.
|
||||
assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`);
|
||||
// Unscoped Stack calls cannot select another board by number.
|
||||
await assert.rejects(task(w, { project: w.project }), refusedWith('request-tracker-refused'));
|
||||
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'));
|
||||
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 }] } }),
|
||||
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'));
|
||||
assert.equal(writes(w), before);
|
||||
assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1);
|
||||
// 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 }] } });
|
||||
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
|
||||
// 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) => {
|
||||
const { w, child } = await tree(t);
|
||||
const { w, child } = await tree(t, {}, true);
|
||||
const pm = w.agent('pm');
|
||||
const made = await task(w, { project: child });
|
||||
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) => {
|
||||
// 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 { w, child, board } = await tree(t, f.edit);
|
||||
const { w, child, board } = await tree(t, f.edit, true);
|
||||
w.agent('pm');
|
||||
f.arm();
|
||||
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`]);
|
||||
});
|
||||
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 coder = w.agent('coder');
|
||||
const { task_ref } = await task(w, { project: child });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { createRequirementsHost } from '../src/builtin.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { Credentials } from '../../bus/src/credentials.mjs';
|
||||
import { FakeVikunja } from '../src/fake.mjs';
|
||||
@@ -93,6 +95,7 @@ export async function world(t, edit = {}) {
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles,
|
||||
projects: { stack: { root: fileURLToPath(new URL('../../../', import.meta.url)) } },
|
||||
tracker: {
|
||||
sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } },
|
||||
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;
|
||||
if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials;
|
||||
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 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 factory = tasksAdapter({
|
||||
trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } },
|
||||
@@ -151,7 +156,7 @@ export async function world(t, edit = {}) {
|
||||
},
|
||||
async restart() {
|
||||
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 });
|
||||
await adapter.start('demo');
|
||||
if (adapter.status()[0].state !== 'ready') throw Error(adapter.status()[0].refused);
|
||||
|
||||
Reference in new issue
Block a user