Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c97c0584f | ||
|
|
1627e24d79 |
No files matched your search
@@ -6012,3 +6012,43 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
|
||||
|
||||
2026-11-04T03:48:47Z | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a before/after | Assigned base 6dd4f92b0; harness had no intake tools and staff.request required a task. Added intake_open/request.open and intake_propose/request.propose only for PM holding message.send, plus intake_status/request.get and intake_projects/request.projects in PM and CTO pre-task manifests, independent of tracker/checkouts. Existing broker authority is unchanged: its request reads still refuse CTO (request-access-refused); tested and retained rather than widening authority. intake_question omitted because no backing request.question verb exists. staff.request/request_staff takes exactly one of request or task_ref with roles/note/decision unchanged, PM-only message.send and chief routing preserved; original request provenance/reachability uses the existing host check without a fabricated task or opened journal. Exported the existing runner toolServer for acceptance tests against production dispatch, including MCP; no new imports or sandbox/process mounts needed (import closure 1/1). PM duty prose and the generated PM staffing prompt describe pre-task intake and staffing. New tests cover actual PM/CTO/coder/reviewer/assistant bundles for pi and claude-code, assistant -> chief -> PM relays, exact user-mode decision with null task/requirement, duplicate open, wrong roles, invalid/unreachable input, exclusive staff references and assistant coordination restrictions. RED on assigned parent: 6 pass/12 fail (dispatch not exported); after exporting only the existing dispatch seam 7 pass/11 fail for missing tools/staff intake. First GREEN attempt 17/18 exposed a test fixture treating message.receive's array as an object, corrected without weakening assertions. Existing required tests first 53/55 and full harness first 222/223 failed only outdated manifest/schema/prompt expectations introduced by this slice; original parent reruns required 55/55 and bundle 22/22 passed. A further generated staffing prompt RED 21/22 proved the stale task-only instruction before updating it. Final gates: request-tools 18/18, required command 55/55, full harness 223/223, bus 1477/1477, business 305/305, sandbox import closure 1/1, all zero failures/skips/cancellations. Full logs, baseline extraction and red evidence: /mnt/storage/scratch/tmp/JHTDYm/; every run used that TMPDIR with pipefail. Local commit only; no push, no role JSON/contract/migration/schema/config/credential changes, no runtime/bootstrap execution, no continuation/PRD/advance/recovery work. Controller owns review, integration and queue/tracker coordination; linked-worktree queue command refused as designed.
|
||||
2026-11-04T04:06:34Z | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a before/after | Before: 6dd4f92b0 rejects operation keys and cannot recover a lost tracker create response. After: optional op reserves an immutable action.allowed tracker.create.prepared event, complete POST body/digest, target board and original PM/bot identity before any create-path network I/O; optional project_key and requirement_snapshot are opaque evidence and visible marker fields. Reusing an op cannot POST again, even after a complete negative scan or a restart before POST. Sync-only id-keyed full scans include closed tasks and require one marker, unchanged create fields, original board and creator; edited/missing/duplicate markers and partial/failed scans retain a deduplicated action.refused tracker.create.wait reconciliation receipt. Host-only recordRecoveredCreate validates the prepared receipt, body, creator and board bucket, atomically records the missing self snapshot/task.created once with original attribution, and has no socket route. Adapter exposes reconcileCreates and inspectProject, startup recovers; live PM retries resume labels/relations through read-before-write and current authority checks, assignment uses the existing separate verb. Legacy calls without new fields preserve their existing behavior; lifecycle callers must reuse a stable op for safe retries. Scope-only calls receive a generated op recorded in evidence. No migration, schema-pin edits, role/contract changes or runtime.mjs edits; packages/tasks/requirements.json and its consistency test unchanged. RED: new recovery suite 0/16 on parent; final regressions 0/21 on parent, expected invalid-request for op. Authority and forged-bucket checks also observed RED before their fixes. Corrections: event kind/subject guards required existing action event kinds instead of inventing task/ tracker kinds; restart fixtures needed historical launch cleanup before a successor claim; initial npm workspace invocation refused before tests and was replaced with per-package npm test. First full bus 1473/1477 exposed the added broker import outside the sandbox source closure, including three managed-launch failures; moved pure identity helpers into already-mounted broker.mjs, with parent project-runtime 3/3 and model-hire-ownership 18/18, then focused 42/42. Second bus 1476/1477 encountered unchanged row 280 sshd fixture cleanup ENOTEMPTY and hung; terminated only that run's owned host-run test and sshd, then waited for completion. Parent host-run was 25/26 with T27 failure (row 280 passed); local isolated host-run 26/26. Final GREEN: recovery 21/21, required verbs/adapter/tree/requirements 61/61, full tasks npm test 121/121, full bus npm test 1477/1477, zero failures/skips, diff check clean. Full logs and parent archive: /mnt/storage/scratch/tmp/CkjqAm/ (gate-recovery.log, gate-required.log, gate-tasks.log, gate-bus.log plus diagnostic history). Queue command refused this linked worktree, so no queue mutation; lead owns integration/queue. Task 5b catalogue validation, requirementsFor, built-in binding, task scope filtering and push product binding remain deferred as directed, pending Task 4. One local commit only; no push or live tracker/network/instance changes.
|
||||
2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access.
|
||||
|
||||
### 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.
|
||||
@@ -2001,3 +2001,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a | Four backed typed intake tools, pre-task PM staff.request with original request provenance, actual pi/claude-code role manifests and production runner/MCP acceptance tests; PM duty and generated staff prose updated. CTO read manifests added while existing broker read refusal remains; intake_question absent because no backing verb. Gates request-tools 18/18, required 55/55, harness 223/223, bus 1477/1477, business 305/305, sandbox imports 1/1, zero skips; logs /mnt/storage/scratch/tmp/JHTDYm/. Parent owns review/integration/queue; local commit only, no push or authority/schema/contract/migration changes.
|
||||
|
||||
2026-11-03 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a | Optional journaled create operations, sync-only complete-scan adoption, host-only recovery with original attribution and PM follow-up resume; no migration. Recovery 21/21, required 61/61, tasks 121/121, bus 1477/1477, zero skips; diagnostics and parent reruns in /mnt/storage/scratch/tmp/CkjqAm/. Task 5b deferred, queue left to lead after linked-worktree refusal; one local commit only, no push or live changes.
|
||||
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push.
|
||||
- 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
|
||||
|
||||
@@ -535,7 +535,7 @@ CREATE TABLE request_events (
|
||||
business TEXT NOT NULL,
|
||||
request TEXT NOT NULL REFERENCES events(id),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','cancelled')),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
at TEXT NOT NULL,
|
||||
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
|
||||
|
||||
+172
-17
@@ -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
|
||||
@@ -123,6 +123,10 @@ export const HOST_STDIN_MAX = 65536;
|
||||
export const HOST_TIMEOUT_DEFAULT = 60;
|
||||
export const HOST_TIMEOUT_MAX = 900;
|
||||
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
|
||||
// Canonical candidate bindings remain pure so the broker's sandbox import closure stays fixed.
|
||||
const orderedPrd = value => Array.isArray(value) ? value.map(orderedPrd) : object(value)
|
||||
? Object.fromEntries(Object.keys(value).sort().map(k => [k, orderedPrd(value[k])])) : value;
|
||||
const digestPrd = value => sha256(JSON.stringify(orderedPrd(value)));
|
||||
// D4: what a host.sudo decision binds, from the raise's args and again from the call's.
|
||||
const hostRun = (host, argv, stdin) => ({
|
||||
host,
|
||||
@@ -156,7 +160,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
|
||||
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console']);
|
||||
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
|
||||
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
|
||||
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
|
||||
@@ -263,6 +267,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');
|
||||
@@ -441,6 +446,10 @@ export class Broker {
|
||||
#secretCheck;
|
||||
#credentialStatus;
|
||||
#requirements;
|
||||
#catalog;
|
||||
#requestJournal;
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
@@ -503,7 +512,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');
|
||||
@@ -556,6 +565,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',
|
||||
@@ -585,6 +600,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) => {
|
||||
@@ -593,6 +609,12 @@ export class Broker {
|
||||
},
|
||||
projectPaths: business => Object.values(this.#business(business).projects ?? {}).flatMap(p => [p.root, p.repo]).filter(p => typeof p === 'string'),
|
||||
raiseDecision: (identity, args) => this.#dispatch(identity, 'decision.raise', args),
|
||||
raisePrdDecision: (identity, args, spec) => this.#dispatch(identity, 'decision.raise', args, spec),
|
||||
prdBusiness: business => structuredClone(this.#business(business)),
|
||||
prdAuthority: (state, binding) => this.#prdAuthority(state, binding),
|
||||
prdApproval: (state, binding) => this.#prdApproval(state, binding),
|
||||
prdDecision: (business, decision) => this.#decisionView(this.#store.get('SELECT * FROM decisions WHERE business=? AND id=?', business, decision)),
|
||||
recordPrd: (identity, project, decision) => this.#consumeAuthority(identity, 'record.write', { target: project }, { operation: 'prd.submit', decision }),
|
||||
readDecision: (business, decision) => {
|
||||
const d = this.#store.get('SELECT * FROM decisions WHERE business=? AND id=?', business, decision);
|
||||
if (!d || d.action !== 'project.bootstrap' || d.route_to !== 'human') fail('request-selection-refused');
|
||||
@@ -613,18 +635,108 @@ export class Broker {
|
||||
if (!s || s.business !== state.business) fail('request-access-refused');
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.user || s.console) fail('human-required');
|
||||
if (s.user || (s.console && !['mode','draft'].includes(operation))) fail('human-required');
|
||||
if (['mode','draft'].includes(operation) && !s.console) fail('human-required');
|
||||
if (s.human) {
|
||||
this.#human(s);
|
||||
if (!s.console) this.#human(s);
|
||||
if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused');
|
||||
if (operation === 'propose') fail('agent-required');
|
||||
if (['propose','prd.submit'].includes(operation)) fail('agent-required');
|
||||
} else {
|
||||
this.#agent(s);
|
||||
if (s.role !== state.pm || this.#business(s.business).roles[s.role]?.definition !== 'pm') fail('request-access-refused');
|
||||
// No separate authority: intake preparation consumes the PM's existing message.send grant.
|
||||
if (operation !== 'get') this.#checkAuthority(s, 'message.send', { target: state.pm });
|
||||
if (operation === 'prd.submit') this.#checkAuthority(s, 'record.write', { target: state.project });
|
||||
else if (operation !== 'get') this.#checkAuthority(s, 'message.send', { target: state.pm });
|
||||
}
|
||||
}
|
||||
#prdAuthority(state, binding) {
|
||||
if (!state || !binding) fail('prd-authority-refused');
|
||||
const b = this.#business(state.business), pm = b.roles[state.pm];
|
||||
if (b.human !== state.owner || pm?.definition !== 'pm' || !pm.authority?.withinRole.includes('record.write')) fail('prd-authority-refused');
|
||||
for (const author of binding.authors ?? []) {
|
||||
const r = b.roles[author.instance];
|
||||
if (r?.definition !== author.definition || !r.authority?.withinRole.includes('record.write')) fail('prd-authority-refused');
|
||||
}
|
||||
let route = { instance: 'human', definition: 'human' };
|
||||
if (state.mode === 'autonomous') {
|
||||
const instance = b.arbiters?.technical, arbiter = b.roles[instance];
|
||||
if (!instance || !arbiter || !arbiter.authority?.withinRole.includes('decision.resolve.technical')) fail('prd-arbiter-unavailable');
|
||||
if (instance === state.pm || arbiter.definition === pm.definition || binding.authors?.some(a => a.instance === instance || a.definition === arbiter.definition)) fail('prd-self-approval');
|
||||
route = { instance, definition: arbiter.definition };
|
||||
}
|
||||
if (binding.route && digestPrd(binding.route) !== digestPrd(route)) fail('prd-authority-refused');
|
||||
return route;
|
||||
}
|
||||
#prdApproval(state, binding) {
|
||||
const route = this.#prdAuthority(state, binding), d = this.#store.get('SELECT * FROM decisions WHERE business=? AND id=?', state.business, binding.decision);
|
||||
const r = d && this.#closed(d.id), raised = d && this.#raised(state.business, d.id);
|
||||
if (!d || d.action !== 'prd.approve' || d.project !== state.project || d.raised_by_role !== state.pm || d.route_to !== route.instance ||
|
||||
d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null || d.class !== 'gated' ||
|
||||
raised?.target !== `prd-${digestPrd(binding.candidate)}` || raised?.approvalChoice !== 'yes' ||
|
||||
(state.mode === 'autonomous' ? d.spec_commit !== binding.candidate.spec_commit || d.spec_path !== SPEC_PATH : d.spec_commit !== null)) fail('prd-authority-refused');
|
||||
if (!r) fail('prd-not-approved');
|
||||
if (r.op !== 'resolved') fail('prd-decision-closed');
|
||||
if (r.choice !== raised.approvalChoice) fail('prd-changes-requested');
|
||||
if (route.instance === 'human') {
|
||||
if (r.by !== state.owner || !['cli','console'].includes(r.via) || (r.via === 'console' && !this.#consoleInput(state.business, d.id, r))) fail('prd-authority-refused');
|
||||
} else if (r.via !== 'broker' || !(this.#store.get("SELECT 1 FROM role_claims WHERE business=? AND role=? AND holder_run=? AND op='claim'", state.business, route.instance, r.by) ||
|
||||
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 { 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;
|
||||
}
|
||||
#requestOrigin(s, request) {
|
||||
const b = this.#business(s.business);
|
||||
const e = this.#store.get("SELECT * FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL", s.business, request);
|
||||
@@ -4158,18 +4270,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
|
||||
@@ -4191,6 +4307,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))
|
||||
)
|
||||
@@ -4202,7 +4320,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),
|
||||
@@ -4211,6 +4329,7 @@ export class Broker {
|
||||
labels: f.labels ?? [],
|
||||
priority,
|
||||
requirement,
|
||||
...(project_key === undefined ? {} : { project_key, requirement_snapshot }),
|
||||
changed_at: at,
|
||||
}))
|
||||
.filter(
|
||||
@@ -4218,7 +4337,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;
|
||||
@@ -4349,16 +4470,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 });
|
||||
});
|
||||
}
|
||||
@@ -4379,6 +4510,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 };
|
||||
});
|
||||
@@ -4501,6 +4633,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);
|
||||
@@ -4516,6 +4649,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];
|
||||
@@ -4808,6 +4942,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.
|
||||
@@ -4887,6 +5022,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);
|
||||
@@ -5070,11 +5206,27 @@ export class Broker {
|
||||
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
|
||||
}
|
||||
this.#processingGate(s, request.verb);
|
||||
if (['request.open','request.get','request.projects','request.propose'].includes(request.verb)) {
|
||||
if (['prd.revoke.console','request.mode.console','request.draft.console'].includes(request.verb)) {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
if (!this.#requests?.catalog) fail('request-not-configured');
|
||||
return Promise.resolve().then(() => request.verb === 'request.draft.console' ? this.#requests.draftConsole(s, structuredClone(args)) : request.verb === 'prd.revoke.console'
|
||||
? 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 (['request.open','request.get','request.projects','request.propose','prd.submit'].includes(request.verb)) {
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (!this.#requests) fail('request-not-configured');
|
||||
const method = request.verb.slice('request.'.length);
|
||||
const method = request.verb === 'prd.submit' ? 'submit' : request.verb.slice('request.'.length);
|
||||
return Promise.resolve().then(() => this.#requests[method](s, structuredClone(args)))
|
||||
.then(result => { this.#secretCheck(result); return result; })
|
||||
.catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
|
||||
@@ -6387,6 +6539,8 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
if (op !== 'seen' && d.action === 'prd.approve' && this.#requests)
|
||||
setImmediate(() => { Promise.resolve().then(() => this.#requests.decisionClosed?.(s.business, d.id)).catch(() => {}); });
|
||||
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
|
||||
return grant ? { ...answer, grant: 'started' } : answer;
|
||||
}
|
||||
@@ -7573,6 +7727,7 @@ export class Broker {
|
||||
// The notice sits in no thread, so the user's thread view shows none of this traffic. Nothing is
|
||||
// launched here: a notice to a role no run holds waits for a claim and does not tell launch.by.
|
||||
#specNotice(business, project, commit, base) {
|
||||
this.#requests?.catalog?.invalidate(business, project, 'requirement-snapshot-revoked', true);
|
||||
const b = this.#business(business);
|
||||
const roles = Object.keys(b.roles).filter((r) => SPEC_REVIEWERS.includes(b.roles[r].definition));
|
||||
if (!roles.length) return;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Task 4: preserve all evidence and guards, widen only lifecycle event kinds.
|
||||
DROP TRIGGER request_events_origin;
|
||||
DROP TRIGGER request_events_revision;
|
||||
DROP TRIGGER request_effects_binding;
|
||||
DROP TRIGGER request_events_no_update;
|
||||
DROP TRIGGER request_events_no_delete;
|
||||
DROP TRIGGER request_events_no_replace;
|
||||
DROP INDEX request_events_request;
|
||||
ALTER TABLE request_events RENAME TO request_events_3p;
|
||||
CREATE TABLE request_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
request TEXT NOT NULL REFERENCES events(id),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
at TEXT NOT NULL,
|
||||
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
|
||||
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
|
||||
UNIQUE (business,request,revision)
|
||||
) STRICT;
|
||||
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
|
||||
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3p ORDER BY seq;
|
||||
DELETE FROM sqlite_sequence WHERE name='request_events';
|
||||
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3p';
|
||||
DROP TABLE request_events_3p;
|
||||
CREATE INDEX request_events_request ON request_events (business,request,seq);
|
||||
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
|
||||
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
|
||||
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
|
||||
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
|
||||
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
|
||||
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
|
||||
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
|
||||
OR (NEW.revision=1) <> (NEW.kind='opened')
|
||||
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
|
||||
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
|
||||
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
|
||||
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
|
||||
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
|
||||
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
|
||||
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
|
||||
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
|
||||
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
|
||||
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { exact, object, id, key, text, digest, validatePlan, refuse } from './re
|
||||
|
||||
export function createRequestLifecycle({ journal, services }) {
|
||||
if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable');
|
||||
let closing = false;
|
||||
const pending = new Map();
|
||||
const serial = (business, request, fn) => {
|
||||
const k = business + '/' + request;
|
||||
@@ -200,9 +201,79 @@ export function createRequestLifecycle({ journal, services }) {
|
||||
return append(state, 'bootstrap.completed', { digest: binding.digest, project: done.project, chat_project: done.chat_project,
|
||||
spec_commit: done.spec_commit, organization_revision: done.organization_revision });
|
||||
}
|
||||
async function submit(identity, args) {
|
||||
exact(args, ['request','revision','op','prd_version','records_commit','prd_path','requirement_ids']);
|
||||
if (!id(args.request) || !id(args.op) || !Number.isSafeInteger(args.revision) || args.revision < 1) refuse('invalid-request');
|
||||
return serial(identity.business, args.request, async () => {
|
||||
let state = stateOf(identity.business, args.request); authorize(identity, state, 'prd.submit');
|
||||
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
|
||||
if (old) {
|
||||
if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict');
|
||||
if (!old.candidate) return state;
|
||||
return { candidate: old.candidate, decision: services.prdDecision(state.business, old.decision) };
|
||||
}
|
||||
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
||||
if (!['discovery','prd-review','planning'].includes(state.phase) || state.wait?.code === 'prd-approval') refuse('request-phase-refused');
|
||||
const { revision, op, ...input } = args;
|
||||
const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business);
|
||||
const recheck = () => {
|
||||
const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit');
|
||||
if (now.revision !== args.revision) refuse('request-revision-conflict');
|
||||
if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked');
|
||||
return now;
|
||||
};
|
||||
return journal.transaction(tx => {
|
||||
state = recheck();
|
||||
let route;
|
||||
try { route = services.prdAuthority(state, { authors: material.authors }); }
|
||||
catch (e) { if (!['prd-arbiter-unavailable','prd-self-approval'].includes(e.code)) throw e; return wait(state, e.code, identity, {}, tx.appendRequest); }
|
||||
const candidate = material.candidate;
|
||||
const decision = services.raisePrdDecision(identity, { action: 'prd.approve', domain: 'technical', project: state.project,
|
||||
target: `prd-${digest(candidate)}`, question: `Approve product PRD ${candidate.prd_version} for ${state.project}, exact candidate ${digest(candidate)} at ${candidate.records_commit}?`,
|
||||
options: [{ key: 'yes', text: 'Approve this exact product PRD version' }, { key: 'no', text: 'Request changes' }], recommendation: 'yes', approvalChoice: 'yes', blocking: false,
|
||||
...(state.mode === 'autonomous' ? { spec_path: 'spec/north-star.md', spec_commit: candidate.spec_commit } : {}) },
|
||||
state.mode === 'autonomous' ? { path: 'spec/north-star.md', commit: candidate.spec_commit } : null);
|
||||
const header = { version: 1, business: state.business, request: state.request, op, kind: 'prd-submit', digest: proposal_digest };
|
||||
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
|
||||
// A replacement retires existing admissions without changing their immutable evidence.
|
||||
if (state.snapshot) services.catalog.invalidate(state.business, state.project, 'prd-replaced');
|
||||
state = stateOf(state.business, state.request);
|
||||
append(state, 'prd.submitted', { candidate, decision: decision.id, op, proposal_digest, authors: material.authors, route }, identity, tx.appendRequest);
|
||||
journal.finishEffect({ ...header, stage: 'committed', result: { decision: decision.id }, code: null });
|
||||
services.recordPrd?.(identity, state.project, decision.id);
|
||||
return { candidate, decision };
|
||||
});
|
||||
});
|
||||
}
|
||||
async function modeConsole(identity, args) {
|
||||
exact(args, ['request','revision','mode_revision','mode','op']);
|
||||
if (!id(args.request) || !id(args.op) || !Number.isSafeInteger(args.revision) || !Number.isSafeInteger(args.mode_revision) || !['guided','autonomous'].includes(args.mode)) refuse('invalid-request');
|
||||
return serial(identity.business, args.request, () => journal.transaction(() => {
|
||||
let state = stateOf(identity.business, args.request); authorize(identity, state, 'mode');
|
||||
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
|
||||
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
|
||||
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
||||
if (!state.project || args.mode_revision !== state.mode_revision) refuse('request-mode-revision-conflict');
|
||||
const mode_revision = journal.changeMode({ business: state.business, project: state.project, revision: args.mode_revision, mode: args.mode, op: args.op, request: args.request });
|
||||
services.catalog.invalidate(state.business, state.project, 'request-mode-changed');
|
||||
for (const row of journal.listRequests()) {
|
||||
if (row.business !== state.business) continue;
|
||||
const attached = stateOf(row.business, row.request);
|
||||
if (attached.project === state.project && attached.phase !== 'cancelled') append(attached, 'mode.changed', { mode: args.mode, mode_revision, op: args.op, proposal_digest });
|
||||
}
|
||||
return stateOf(state.business, state.request);
|
||||
}));
|
||||
}
|
||||
async function resume(business, request) {
|
||||
return serial(business, request, async () => {
|
||||
const state = stateOf(business, request);
|
||||
if (state.wait?.code === 'prd-approval') {
|
||||
try { await services.catalog.activate({ request, decision: state.wait.subject }); return stateOf(business, request); }
|
||||
catch (e) {
|
||||
if (e.code === 'prd-not-approved') return state;
|
||||
return append(stateOf(business, request), 'prd.invalidated', { code: e.code ?? 'prd-activation-failed' });
|
||||
}
|
||||
}
|
||||
if (state.wait?.kind !== 'decision') return runBootstrap(state);
|
||||
const close = services.readDecision(business, state.selection.decision);
|
||||
if (!close || close.op === 'seen') return state;
|
||||
@@ -231,6 +302,21 @@ export function createRequestLifecycle({ journal, services }) {
|
||||
return runBootstrap(confirmed);
|
||||
});
|
||||
}
|
||||
return Object.freeze({ open, get, projects, propose, resume,
|
||||
return Object.freeze({ open, get, projects, propose, submit, modeConsole,
|
||||
draftConsole: async (identity, args) => {
|
||||
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
|
||||
const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft');
|
||||
if (!state.candidate) refuse('prd-candidate-required');
|
||||
const material = await services.readCandidate(state.candidate, state.business);
|
||||
const current = stateOf(state.business, state.request); authorize(identity, current, 'draft');
|
||||
if (current.revision !== state.revision) refuse('request-revision-conflict');
|
||||
return { candidate: material.candidate, prd: material.prd, requirements: material.requirements };
|
||||
}, resume, catalog: services.catalog,
|
||||
decisionClosed: (business, decision) => {
|
||||
if (closing) return Promise.resolve();
|
||||
for (const row of journal.listRequests()) if (row.business === business && journal.readRequest(business, row.request).wait?.subject === decision)
|
||||
return resume(business, row.request);
|
||||
},
|
||||
close: async () => { closing = true; await Promise.allSettled([...pending.values()]); await services.catalog?.drain(); },
|
||||
recover: async () => { const states = []; for (const { business, request } of journal.listRequests()) states.push(await resume(business, request)); return states; } });
|
||||
}
|
||||
@@ -48,6 +48,21 @@ export function validatePlan(p) {
|
||||
try { checkVars(p.project_vars, 'project', 'request resource plan'); } catch { refuse(code); }
|
||||
return p;
|
||||
}
|
||||
export function validateCandidate(c) {
|
||||
const code = 'invalid-prd-candidate';
|
||||
exact(c, ['version','request','project','prd_version','records_commit','prd_path','prd_blob','requirements','spec_commit','mode_revision'], [], code);
|
||||
if (c.version !== 1 || !id(c.request) || !key(c.project) || typeof c.prd_version !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(c.prd_version) ||
|
||||
!oid(c.records_commit) || c.prd_path !== `docs/prd-${c.prd_version}.md` || !oid(c.prd_blob) || !oid(c.spec_commit) || !positive(c.mode_revision) ||
|
||||
!Array.isArray(c.requirements) || c.requirements.length < 1 || c.requirements.length > 64) refuse(code);
|
||||
const seen = new Set();
|
||||
for (const r of c.requirements) {
|
||||
exact(r, ['id','title','path','blob'], [], code);
|
||||
if (typeof r.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(r.id) || !text(r.title, 512) || !r.title.trim() ||
|
||||
r.path !== `requirements/${r.id}.json` || !oid(r.blob) || seen.has(r.path)) refuse(code);
|
||||
seen.add(r.path);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function initial(business, request, b) {
|
||||
exact(b, ['source_message','thread','session','owner','pm','intent'], [], 'invalid-request-event');
|
||||
if (!id(b.source_message) || !nullableId(b.thread) || !nullableId(b.session) || !id(b.owner) || !key(b.pm)) refuse('invalid-request-event');
|
||||
@@ -107,6 +122,32 @@ function reduce(state, e) {
|
||||
if (s.phase !== 'bootstrap' || s.plan_digest !== b.digest || b.project !== s.project || !id(b.chat_project) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code);
|
||||
s.chat_project = b.chat_project; s.spec_commit = b.spec_commit; s.phase = 'discovery';
|
||||
s.wait = { kind: 'setup', code: 'request-discovery-unavailable', subject: s.request, resume_phase: 'discovery', observed_revision: s.revision };
|
||||
} else if (e.kind === 'prd.submitted') {
|
||||
exact(b, ['candidate','decision','op','proposal_digest','authors','route'], [], code);
|
||||
validateCandidate(b.candidate);
|
||||
exact(b.route, ['instance','definition'], [], code);
|
||||
if (!key(b.route.instance) || !key(b.route.definition)) refuse(code);
|
||||
if (!['discovery','prd-review','planning'].includes(s.phase) || !id(b.decision) || !id(b.op) || !hash(b.proposal_digest) ||
|
||||
b.candidate?.request !== s.request || b.candidate.project !== s.project || b.candidate.mode_revision !== s.mode_revision || !Array.isArray(b.authors) || !b.authors.length) refuse(code);
|
||||
for (const a of b.authors) { exact(a, ['definition','instance','commit'], [], code); if (!key(a.definition) || !key(a.instance) || !oid(a.commit)) refuse(code); }
|
||||
s.candidate = structuredClone(b.candidate); s.phase = 'prd-review'; s.snapshot = null; s.spec_commit = b.candidate.spec_commit;
|
||||
s.wait = { kind: 'decision', code: 'prd-approval', subject: b.decision, resume_phase: 'prd-review', observed_revision: s.revision };
|
||||
} else if (e.kind === 'prd.activated') {
|
||||
exact(b, ['decision','snapshot'], [], code);
|
||||
if (s.phase !== 'prd-review' || s.wait?.subject !== b.decision || !id(b.snapshot)) refuse(code);
|
||||
s.snapshot = b.snapshot; s.phase = 'planning';
|
||||
s.wait = { kind: 'setup', code: 'request-planning-unavailable', subject: s.request, resume_phase: 'planning', observed_revision: s.revision };
|
||||
} else if (e.kind === 'prd.invalidated') {
|
||||
exact(b, ['code'], [], code);
|
||||
if (!text(b.code, 128)) refuse(code);
|
||||
s.snapshot = null; s.candidate = null; s.phase = 'discovery';
|
||||
s.wait = { kind: 'setup', code: b.code, subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
|
||||
} else if (e.kind === 'mode.changed') {
|
||||
exact(b, ['mode','mode_revision','op','proposal_digest'], [], code);
|
||||
if (!s.project || !['guided','autonomous'].includes(b.mode) || !positive(b.mode_revision) || !id(b.op) || !hash(b.proposal_digest)) refuse(code);
|
||||
s.mode = b.mode; s.mode_revision = b.mode_revision;
|
||||
s.candidate = null; s.snapshot = null; s.phase = 'discovery';
|
||||
s.wait = { kind: 'setup', code: 'request-mode-changed', subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
|
||||
} else if (e.kind === 'cancelled') {
|
||||
exact(b, ['decision'], [], code);
|
||||
if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision) refuse(code);
|
||||
@@ -115,6 +156,12 @@ function reduce(state, e) {
|
||||
return s;
|
||||
}
|
||||
export function createRequestStore(store) {
|
||||
let composing = false;
|
||||
const compose = fn => {
|
||||
const before = composing; composing = true;
|
||||
try { return fn(); } finally { composing = before; }
|
||||
};
|
||||
const atomic = fn => composing ? fn() : store.transaction(() => compose(fn));
|
||||
const validateIntent = (event, origin) => {
|
||||
if (event.kind === 'selection.proposed' && (!Array.isArray(event.body.bindings) ||
|
||||
event.body.bindings.some(b => !object(b?.plan?.intent) || !equal(b.plan.intent, origin?.intent)))) refuse('invalid-request-event');
|
||||
@@ -150,6 +197,18 @@ export function createRequestStore(store) {
|
||||
if (!d || d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null ||
|
||||
d.raised_by_role !== state.pm || store.get("SELECT 1 FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired')", d.id)) refuse('invalid-request-event');
|
||||
}
|
||||
if (e.kind === 'prd.submitted') {
|
||||
const d = store.get('SELECT * FROM decisions WHERE business=? AND id=?', e.business, e.body.decision);
|
||||
if (!d || d.action !== 'prd.approve' || d.project !== state.project || d.raised_by_role !== state.pm || d.raised_by_role !== e.actor.role ||
|
||||
d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null || d.route_to !== e.body.route.instance) refuse('invalid-request-event');
|
||||
}
|
||||
if (e.kind === 'prd.activated') {
|
||||
const row = store.get('SELECT body FROM request_snapshots WHERE id=? AND business=? AND project=?', e.body.snapshot, e.business, state.project);
|
||||
if (!row || JSON.parse(row.body).approval.decision !== e.body.decision) refuse('invalid-request-event');
|
||||
}
|
||||
if (e.kind === 'selection.confirmed' && !store.get('SELECT 1 FROM request_project_events WHERE business=? AND project=?', e.business, next.project))
|
||||
store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,1,'attached',?)",
|
||||
new Date().toISOString(), e.business, next.project, JSON.stringify({ mode: next.mode, request: e.request }));
|
||||
// A bootstrap outcome follows only from the receipts it names, so the journal cannot claim a project
|
||||
// that was not activated, or retreat from one whose mapping already landed.
|
||||
if (e.kind === 'bootstrap.completed' || e.kind === 'bootstrap.stale') {
|
||||
@@ -170,7 +229,7 @@ export function createRequestStore(store) {
|
||||
!['bootstrap','prd-submit','prd-activate','task-create','continuation','delivery'].includes(r.kind) ||
|
||||
!(prepare ? r.stage === 'prepared' && r.result === null && r.code === null : ['committed','uncertain','refused'].includes(r.stage)) ||
|
||||
!(r.result === null || object(r.result)) || !(r.code === null || text(r.code, 128))) refuse('invalid-request-effect');
|
||||
return store.transaction(() => {
|
||||
return atomic(() => {
|
||||
if (!readRequest(r.business, r.request)) refuse('request-not-found');
|
||||
const rows = store.all('SELECT version,business,request,op,kind,digest,stage,result,code FROM request_effects WHERE business=? AND request=? AND op=? ORDER BY seq', r.business, r.request, r.op)
|
||||
.map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) }));
|
||||
@@ -186,9 +245,19 @@ export function createRequestStore(store) {
|
||||
return structuredClone(r);
|
||||
});
|
||||
}
|
||||
return Object.freeze({ readRequest, appendRequest: e => store.transaction(() => appendInTransaction(e)),
|
||||
const projectState = (business, project) => {
|
||||
const row = store.get('SELECT * FROM request_project_events WHERE business=? AND project=? ORDER BY revision DESC LIMIT 1', business, project);
|
||||
if (row) return { revision: row.revision, ...JSON.parse(row.body) };
|
||||
// Task 1/2 databases predate mode writers. Their confirmed selection is the durable initial mode.
|
||||
for (const r of store.all('SELECT DISTINCT request FROM request_events WHERE business=?', business)) {
|
||||
const state = readRequest(business, r.request);
|
||||
if (state.project === project && state.mode_revision > 0) return { revision: state.mode_revision, mode: state.mode, request: state.request };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return Object.freeze({ compose, readRequest, appendRequest: e => atomic(() => appendInTransaction(e)),
|
||||
prepareEffect: r => receipt(r, true), finishEffect: r => receipt(r, false),
|
||||
transaction: fn => store.transaction(() => fn({ appendRequest: appendInTransaction, readRequest })),
|
||||
transaction: fn => atomic(() => fn({ appendRequest: appendInTransaction, readRequest })),
|
||||
// Receipts read back, parsed. Hosts use these to resume a bootstrap and to rebuild readiness.
|
||||
effects: (business, request) => {
|
||||
if (!key(business) || !id(request)) refuse('invalid-request');
|
||||
@@ -201,6 +270,36 @@ export function createRequestStore(store) {
|
||||
.map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) }));
|
||||
},
|
||||
listRequests: () => store.all('SELECT DISTINCT business,request FROM request_events ORDER BY business,request'),
|
||||
stateForRequest: request => {
|
||||
const row = store.get('SELECT business FROM request_events WHERE request=? LIMIT 1', request);
|
||||
return row ? readRequest(row.business, request) : null;
|
||||
},
|
||||
submission: (business, request, decision) => {
|
||||
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='prd.submitted' AND json_extract(body,'$.decision')=?", business, request, decision);
|
||||
return row ? JSON.parse(row.body) : null;
|
||||
},
|
||||
projectState,
|
||||
changeMode: ({ business, project, revision, mode, op, request }) => atomic(() => {
|
||||
const last = projectState(business, project);
|
||||
if ((last?.revision ?? 0) !== revision) refuse('request-mode-revision-conflict');
|
||||
store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,?,'mode.changed',?)",
|
||||
new Date().toISOString(), business, project, revision + 1, JSON.stringify({ mode, op, request }));
|
||||
return revision + 1;
|
||||
}),
|
||||
snapshotRequest: snapshot => store.get("SELECT request FROM request_effects WHERE kind='prd-activate' AND stage='committed' AND json_extract(result,'$.snapshot')=?", snapshot)?.request,
|
||||
snapshot: snapshot => {
|
||||
const row = store.get('SELECT body FROM request_snapshots WHERE id=?', snapshot);
|
||||
return row ? JSON.parse(row.body) : null;
|
||||
},
|
||||
snapshotEvents: (business, project) => store.all('SELECT * FROM request_snapshot_events WHERE business=? AND project=? ORDER BY revision', business, project),
|
||||
publishSnapshot: snapshot => store.run('INSERT INTO request_snapshots(id,at,business,project,body) VALUES(?,?,?,?,?)',
|
||||
snapshot.id, new Date().toISOString(), snapshot.business, snapshot.project, JSON.stringify(snapshot)),
|
||||
snapshotEvent: ({ business, project, snapshot, kind, cause }) => {
|
||||
const revision = 1 + (store.get('SELECT max(revision) n FROM request_snapshot_events WHERE business=? AND project=?', business, project).n ?? 0);
|
||||
store.run('INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,?,?,?,?,?,?)',
|
||||
new Date().toISOString(), business, project, revision, snapshot, kind, cause);
|
||||
return { snapshot, revision, kind, cause };
|
||||
},
|
||||
proposal: (business, request, op) => {
|
||||
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND json_extract(body,'$.op')=? ORDER BY seq DESC LIMIT 1", business, request, op);
|
||||
return row ? JSON.parse(row.body) : null;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Host-only admission. The journal's readCandidate/authority/approval adapters read current host state.
|
||||
import { exact, id, key, text, digest, refuse } from './request-store.mjs';
|
||||
export function createRequirementCatalog({ journal, readSpec, builtinBinding }) {
|
||||
const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request,
|
||||
expectedRevision: state.revision, kind, body, actor: { role: null, run: null } });
|
||||
const eventFor = snap => journal.snapshotEvents(snap.business, snap.project).filter(e => e.snapshot === snap.id).at(-1);
|
||||
function invalidate(business, project, code, inTransaction = false) {
|
||||
return (inTransaction ? journal.compose : journal.transaction)(() => {
|
||||
const latest = new Map(journal.snapshotEvents(business, project).map(e => [e.snapshot, e]));
|
||||
for (const e of latest.values()) if (e.kind === 'activated') journal.snapshotEvent({ business, project, snapshot: e.snapshot, kind: 'revoked', cause: code });
|
||||
for (const row of journal.listRequests()) {
|
||||
if (row.business !== business) continue;
|
||||
const state = journal.readRequest(business, row.request);
|
||||
if (state.project === project && (state.candidate || state.snapshot)) append(state, 'prd.invalidated', { code });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function resolve(args) {
|
||||
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) {
|
||||
if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin;
|
||||
refuse('requirement-snapshot-required');
|
||||
}
|
||||
const snap = journal.snapshot(snapshot);
|
||||
if (!snap || snap.business !== business || snap.project !== project) refuse('requirement-snapshot-refused');
|
||||
if (eventFor(snap)?.kind !== 'activated') refuse('requirement-snapshot-revoked');
|
||||
const spec = await readSpec({ business, project });
|
||||
const mode = journal.projectState(business, project);
|
||||
if (!spec.confirmed || spec.commit !== snap.spec_commit || mode?.revision !== snap.mode_revision) {
|
||||
invalidate(business, project, 'requirement-snapshot-revoked'); refuse('requirement-snapshot-revoked');
|
||||
}
|
||||
const state = journal.stateForRequest(journal.snapshotRequest(snap.id));
|
||||
const binding = state && journal.submission(business, state.request, snap.approval.decision);
|
||||
const authority = journal.authority(state, binding);
|
||||
if (digest(journal.approval(state, binding)) !== digest(snap.approval)) refuse('prd-authority-refused');
|
||||
// Reread immutable records, including selected-path equality at HEAD, before admitting new work.
|
||||
const material = await journal.readCandidate(binding.candidate, business);
|
||||
if (digest(material.candidate) !== digest(binding.candidate) || digest(material.authors) !== digest(snap.authors) ||
|
||||
digest(material.candidate.requirements.map((r, i) => ({ ...r, text: material.requirements[i].text }))) !== digest(snap.requirements)) refuse('prd-candidate-stale');
|
||||
if (digest(journal.authority(journal.stateForRequest(state.request), binding)) !== digest(authority) || eventFor(snap)?.kind !== 'activated' || journal.projectState(business, project)?.revision !== snap.mode_revision) refuse('requirement-snapshot-revoked');
|
||||
return structuredClone(snap);
|
||||
}
|
||||
async function activateOnce(args) {
|
||||
exact(args, ['request','decision']); if (!id(args.request) || !id(args.decision)) refuse('invalid-request');
|
||||
const state = journal.stateForRequest(args.request) ?? refuse('request-not-found');
|
||||
const binding = journal.submission(state.business, state.request, args.decision) ?? refuse('prd-candidate-required');
|
||||
const old = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
|
||||
if (old) return structuredClone(journal.snapshot(old.result.snapshot));
|
||||
if (state.phase !== 'prd-review' || state.wait?.subject !== args.decision || digest(state.candidate) !== digest(binding.candidate)) refuse('prd-admission-revoked');
|
||||
const authority = journal.authority(state, binding), approval = journal.approval(state, binding);
|
||||
const material = await journal.readCandidate(binding.candidate, state.business);
|
||||
if (digest(material.candidate) !== digest(binding.candidate) || digest(material.authors) !== digest(binding.authors)) refuse('prd-candidate-stale');
|
||||
const spec = await readSpec({ business: state.business, project: state.project });
|
||||
if (!spec.confirmed || spec.commit !== binding.candidate.spec_commit) refuse('stale-spec');
|
||||
return journal.transaction(() => {
|
||||
const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
|
||||
if (prior) return structuredClone(journal.snapshot(prior.result.snapshot));
|
||||
const current = journal.readRequest(state.business, state.request);
|
||||
if (current.revision !== state.revision || current.wait?.subject !== args.decision || journal.projectState(state.business, state.project)?.revision !== binding.candidate.mode_revision) refuse('prd-admission-revoked');
|
||||
if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked');
|
||||
const c = binding.candidate, snapshot = { version: 1, id: `prd-${digest({ business: state.business, candidate: c, decision: args.decision })}`,
|
||||
business: state.business, project: state.project, prd_version: c.prd_version, records_commit: c.records_commit, prd_path: c.prd_path,
|
||||
prd_blob: c.prd_blob, spec_commit: c.spec_commit, requirements: c.requirements.map((r, i) => ({ ...r, text: material.requirements[i].text })),
|
||||
authors: material.authors, approval, mode_revision: c.mode_revision, origin: 'product' };
|
||||
const header = { version: 1, business: state.business, request: state.request, op: `prd-activate:${args.decision}`, kind: 'prd-activate', digest: digest(c) };
|
||||
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
|
||||
journal.publishSnapshot(snapshot);
|
||||
journal.snapshotEvent({ business: state.business, project: state.project, snapshot: snapshot.id, kind: 'activated', cause: args.decision });
|
||||
append(current, 'prd.activated', { decision: args.decision, snapshot: snapshot.id });
|
||||
journal.finishEffect({ ...header, stage: 'committed', result: { snapshot: snapshot.id, decision: args.decision }, code: null });
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
function revoke(identity, args) {
|
||||
exact(args, ['project','snapshot','revision','reason','op']);
|
||||
if (!key(args.project) || !id(args.snapshot) || !Number.isSafeInteger(args.revision) || args.revision < 1 || !text(args.reason, 1024) || !id(args.op)) refuse('invalid-request');
|
||||
return journal.transaction(() => {
|
||||
const snap = journal.snapshot(args.snapshot);
|
||||
if (!snap || snap.business !== identity.business || snap.project !== args.project) refuse('requirement-snapshot-refused');
|
||||
const cause = JSON.stringify({ op: args.op, reason: args.reason, by: identity.human, digest: digest(args) });
|
||||
const events = journal.snapshotEvents(identity.business, args.project), old = events.find(e => e.kind === 'revoked' && e.cause === cause);
|
||||
if (old) return { snapshot: old.snapshot, revision: old.revision, kind: old.kind, cause: old.cause };
|
||||
if (events.some(e => { try { return JSON.parse(e.cause).op === args.op; } catch { return false; } })) refuse('request-operation-conflict');
|
||||
if ((events.at(-1)?.revision ?? 0) !== args.revision) refuse('request-revision-conflict');
|
||||
const result = journal.snapshotEvent({ business: identity.business, project: args.project, snapshot: snap.id, kind: 'revoked', cause });
|
||||
for (const row of journal.listRequests()) {
|
||||
const state = journal.readRequest(row.business, row.request);
|
||||
if (row.business === identity.business && state.project === args.project && state.snapshot === snap.id) append(state, 'prd.invalidated', { code: 'requirement-snapshot-revoked' });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
const pending = new Map();
|
||||
function activate(args) {
|
||||
const operation = `${args?.request}/${args?.decision}`;
|
||||
if (pending.has(operation)) return pending.get(operation);
|
||||
const job = activateOnce(args).finally(() => { if (pending.get(operation) === job) pending.delete(operation); });
|
||||
pending.set(operation, job); return job;
|
||||
}
|
||||
return Object.freeze({ resolve, activate, invalidate, revoke, drain: () => Promise.allSettled([...pending.values()]) });
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
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';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -265,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)))
|
||||
@@ -563,12 +567,24 @@ export async function startBroker({
|
||||
if (Object.hasOwn(businesses[id].projects ?? {}, project)) return { ready: true, revision, code: null };
|
||||
return no(declared ? 'project-pending' : 'project-unknown');
|
||||
}
|
||||
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
|
||||
broker = new Broker({
|
||||
requestLifecycle: services => (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services,
|
||||
requirementsHost,
|
||||
requestLifecycle: services => {
|
||||
const readCandidate = (candidate, business) => {
|
||||
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
|
||||
return readPrdMaterial({ business: services.prdBusiness(business), dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
|
||||
requirement_ids: candidate.requirement_ids ?? candidate.requirements.map(r => r.id) });
|
||||
};
|
||||
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
|
||||
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
|
||||
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: {
|
||||
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier,
|
||||
chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } })),
|
||||
chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } }));
|
||||
},
|
||||
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
|
||||
access: access ? () => loadAccess(access.file, access.context) : null,
|
||||
// FED R4 (#1801, design 3.7 trigger 1): a request stats the file and reads it only when this
|
||||
@@ -822,7 +838,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.
|
||||
@@ -963,6 +979,7 @@ export async function startBroker({
|
||||
return {
|
||||
broker,
|
||||
requestLifecycle,
|
||||
requirementCatalog: requestLifecycle.catalog,
|
||||
admission,
|
||||
credentials,
|
||||
// P2 consumes this inside the bus only. No process IPC/verb returns it.
|
||||
@@ -1033,6 +1050,7 @@ export async function startBroker({
|
||||
await stopSync();
|
||||
await server.close();
|
||||
await adapter?.close?.();
|
||||
await requestLifecycle.close();
|
||||
await processing?.close();
|
||||
store.close();
|
||||
credentials.close();
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = 'ae56fbdcb5ea9df3a15a47c7978f5c22516cf1c85dd9a1c9885a63bf7f80d252';
|
||||
export const SCHEMA_VERSION = '3p';
|
||||
export const SCHEMA_SHA256 = '2a6a161b3d671418e690942f02203b4a014e939b46ae8f651da11f091dac5455';
|
||||
export const SCHEMA_VERSION = '3q';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -93,6 +93,9 @@ const migrations = [{
|
||||
from: '3o',
|
||||
to: '3p',
|
||||
sql: readFileSync(new URL('./migrations/3o-3p.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
from: '3p', to: '3q',
|
||||
sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -90,6 +90,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
|
||||
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
|
||||
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -147,7 +148,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -159,14 +160,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -614,7 +614,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3q';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3p', to: '3q', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3r';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3q', to: '3r', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3p ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3q ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -86,8 +86,8 @@ function setup(t) {
|
||||
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
|
||||
}
|
||||
|
||||
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
|
||||
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console']);
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
assert.ok(Object.isFrozen(CONSOLE_VERBS));
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
|
||||
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
|
||||
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -101,7 +102,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -127,7 +128,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -138,14 +139,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
|
||||
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
|
||||
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -106,7 +107,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -131,7 +132,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -142,14 +143,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../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 { createRequestStore } from '../src/request-store.mjs';
|
||||
|
||||
async function setup(t, mode = 'autonomous') {
|
||||
const root = tmp(t, 'activation-'), 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 } }));
|
||||
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
|
||||
const system = loadSystem({ env: f.env });
|
||||
const boot = async () => {
|
||||
const config = bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} });
|
||||
const bus = await startBroker(config); t.after(() => bus.close().catch(() => {})); return bus;
|
||||
};
|
||||
const bus = await boot(), caps = {};
|
||||
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
|
||||
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
|
||||
const bind = b => {
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.console = b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
|
||||
caps.reader = b.bindReader({ business: 'demo' });
|
||||
for (const role of ['pm','cto','coder']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); }
|
||||
};
|
||||
bind(bus.broker);
|
||||
const call = (who, verb, args = {}) => bus.broker.request(caps[who], { verb, args });
|
||||
const sent = call('human', 'message.send', { to: 'pm', body: 'Build a timer.\n' });
|
||||
let state = await call('pm', 'request.open', { request: sent.request });
|
||||
state = await call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'plan', source_project: 'stack', project: 'timer', name: 'Timer', tracker_project: 2 });
|
||||
call('human', 'decision.resolve', { id: state.selection.decision, choice: mode });
|
||||
state = await bus.requestLifecycle.resume('demo', sent.request);
|
||||
const context = { business: { id: 'demo', human: 'jason', projects: { timer: { root: join(f.dataRoot, 'products/demo/timer/code') } } }, project: 'timer', dataRoot: f.dataRoot };
|
||||
const p = await prdFixture(t, context);
|
||||
const args = { request: sent.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: p.args.records_commit, prd_path: p.args.prd_path, requirement_ids: p.args.requirement_ids };
|
||||
const submit = () => call('pm', 'prd.submit', args);
|
||||
const approve = (submission, who = mode === 'guided' ? 'human' : 'cto') => call(who, 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
|
||||
const activate = submission => bus.requirementCatalog.activate({ request: sent.request, decision: submission.decision.id });
|
||||
return { ...p, f, bus, caps, call, state, request: sent.request, args, submit, approve, activate, boot, bind, config: bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} }), store, journal: createRequestStore(store) };
|
||||
}
|
||||
|
||||
test('autonomous PM submits technical PRD to distinct CTO, no task before atomic idempotent activation', async t => {
|
||||
const f = await setup(t), submission = await f.submit();
|
||||
assert.equal(submission.decision.action, 'prd.approve'); assert.equal(submission.decision.route_to, 'cto');
|
||||
assert.equal(submission.decision.authorization.target.startsWith('prd-'), true);
|
||||
assert.equal(submission.decision.spec_commit, submission.candidate.spec_commit); assert.equal(submission.decision.blocking, false);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM task_snapshots').n, 0);
|
||||
await assert.rejects(f.activate(submission), { code: 'prd-not-approved' });
|
||||
assert.throws(() => f.call('pm', 'decision.resolve', { id: submission.decision.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
f.approve(submission);
|
||||
f.call('cto', 'role.release');
|
||||
const snap = await f.activate(submission);
|
||||
assert.equal(snap.requirements[0].text, 'A working timer.'); assert.equal(snap.origin, 'product');
|
||||
assert.deepEqual(await f.activate(submission), snap);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 1);
|
||||
assert.equal(f.journal.readRequest('demo', f.request).phase, 'planning');
|
||||
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'prd-activate' && e.stage === 'committed').length, 1);
|
||||
assert.equal((await f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id, requirement: 'REQ-POMO-1' })).id, snap.id);
|
||||
});
|
||||
test('guided routes the same PRD action to owner without a routing citation', async t => {
|
||||
const f = await setup(t, 'guided'), sub = await f.submit();
|
||||
assert.equal(sub.decision.route_to, 'human'); assert.equal(sub.decision.spec_commit, null);
|
||||
assert.throws(() => f.approve(sub, 'cto'), { code: 'human-required' });
|
||||
f.approve(sub); assert.equal((await f.activate(sub)).approval.by, 'jason');
|
||||
});
|
||||
test('submit is PM-only, strict, revision checked, idempotent and size rejected before a decision', async t => {
|
||||
const f = await setup(t);
|
||||
for (const who of ['coder','cto','human','reader']) await assert.rejects(Promise.resolve().then(() => f.call(who, 'prd.submit', f.args)));
|
||||
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, prd_blob: 'forged' }));
|
||||
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, revision: 1 }), { code: 'request-revision-conflict' });
|
||||
const count = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
const records_commit = f.save('docs/prd-1.0.md', 'x'.repeat(32769));
|
||||
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, records_commit }), { code: 'prd-candidate-size' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
|
||||
});
|
||||
for (const path of ['docs/prd-1.0.md','requirements/REQ-POMO-1.json','spec/north-star.md']) test(`activation rereads current selected content and intent: ${path}`, async t => {
|
||||
const f = await setup(t), sub = await f.submit(); f.approve(sub);
|
||||
f.save(path, path.endsWith('.json') ? JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Other', text: 'Other', status: 'accepted' }) : 'Changed\n');
|
||||
await assert.rejects(f.activate(sub));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
|
||||
});
|
||||
test('forged accepted status is no authority, wrong project and business cannot borrow a snapshot', async t => {
|
||||
const f = await setup(t);
|
||||
f.args.records_commit = f.save('requirements/REQ-POMO-1.json', JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'A working timer.', status: 'accepted' }), 'pm.pm');
|
||||
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer' }), { code: 'requirement-snapshot-required' });
|
||||
const sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
|
||||
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'stack', requirement_snapshot: snap.id }));
|
||||
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'other', project: 'timer', snapshot: snap.id }));
|
||||
});
|
||||
test('fresh runtime resolves identical approved snapshot from committed records and journal alone', async t => {
|
||||
const f = await setup(t), sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
|
||||
await f.bus.close(); const next = await f.boot();
|
||||
assert.deepEqual(await next.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), snap);
|
||||
});
|
||||
test('owner-only explicit revocation rejects stale revisions and makes pending work wait for replacement', async t => {
|
||||
const f = await setup(t), sub = await f.submit(); f.approve(sub); const snap = await f.activate(sub);
|
||||
const args = { project: 'timer', snapshot: snap.id, revision: 1, reason: 'Replace requirements', op: 'revoke1' };
|
||||
for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'prd.revoke.console', args), { code: 'human-required' });
|
||||
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, revision: 0 }));
|
||||
const revoked = await f.call('console', 'prd.revoke.console', args);
|
||||
assert.deepEqual(await f.call('console', 'prd.revoke.console', args), revoked);
|
||||
await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
|
||||
assert.equal(f.journal.readRequest('demo', f.request).wait.code, 'requirement-snapshot-revoked');
|
||||
});
|
||||
test('mode and spec changes invalidate admissions and active snapshots', async t => {
|
||||
const f = await setup(t), sub = await f.submit(); f.approve(sub);
|
||||
let state = f.journal.readRequest('demo', f.request);
|
||||
await f.call('console', 'request.mode.console', { request: f.request, revision: state.revision, mode_revision: state.mode_revision, mode: 'guided', op: 'mode1' });
|
||||
await assert.rejects(f.activate(sub));
|
||||
state = f.journal.readRequest('demo', f.request);
|
||||
const next = await f.call('pm', 'prd.submit', { ...f.args, revision: state.revision, op: 'prd2' });
|
||||
f.approve(next, 'human'); const snap = await f.activate(next);
|
||||
const spec = await f.call('human', 'project.file', { project: 'timer', path: 'spec/north-star.md' });
|
||||
await f.call('human', 'project.file.save', { project: 'timer', path: spec.path, base: spec.commit, content: 'Changed intent\n' });
|
||||
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
|
||||
});
|
||||
|
||||
for (const fault of ['missing-authority','self-instance','same-definition']) test(`autonomous arbiter ${fault} is a setup wait without a decision`, async t => {
|
||||
const f = await setup(t), b = structuredClone(f.config.businesses.demo);
|
||||
if (fault === 'missing-authority') b.roles.cto.authority.withinRole = b.roles.cto.authority.withinRole.filter(a => a !== 'decision.resolve.technical');
|
||||
if (fault === 'self-instance') b.arbiters.technical = 'pm';
|
||||
if (fault === 'same-definition') b.roles.cto.definition = 'pm';
|
||||
f.bus.broker.reloadBusiness('demo', b);
|
||||
const count = f.store.get('SELECT count(*) n FROM decisions').n, result = await f.submit();
|
||||
assert.equal(result.wait.kind, 'setup'); assert.match(result.wait.code, /^prd-(arbiter-unavailable|self-approval)$/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
|
||||
});
|
||||
test('activation rechecks current role authority and cannot use another candidate decision', async t => {
|
||||
const f = await setup(t), sub = await f.submit(); f.approve(sub);
|
||||
await assert.rejects(f.bus.requirementCatalog.activate({ request: f.request, decision: f.state.selection.decision }), { code: 'prd-candidate-required' });
|
||||
const b = structuredClone(f.config.businesses.demo); b.roles.cto.authority.withinRole = b.roles.cto.authority.withinRole.filter(a => a !== 'decision.resolve.technical');
|
||||
f.bus.broker.reloadBusiness('demo', b);
|
||||
await assert.rejects(f.activate(sub), { code: 'prd-arbiter-unavailable' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
|
||||
});
|
||||
test('submission replay has one decision and rejects a changed operation binding', async t => {
|
||||
const f = await setup(t), sub = await f.submit();
|
||||
assert.deepEqual(await f.submit(), sub);
|
||||
await assert.rejects(f.call('pm', 'prd.submit', { ...f.args, prd_version: '2.0' }), { code: 'request-operation-conflict' });
|
||||
});
|
||||
test('changed current intent is refused before submission creates a decision', async t => {
|
||||
const f = await setup(t), count = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
f.save('spec/north-star.md', 'Changed spec\n');
|
||||
await assert.rejects(f.submit(), { code: 'stale-spec' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count);
|
||||
});
|
||||
test('activation rollback leaves no snapshot, receipt or continuation after an insert failure', async t => {
|
||||
const f = await setup(t), sub = await f.submit();
|
||||
// A host fixture trigger injects a failure after the snapshot insert, before its activation event.
|
||||
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'));
|
||||
db.exec("CREATE TRIGGER reject_activation BEFORE INSERT ON request_snapshot_events BEGIN SELECT RAISE(ABORT,'fixture-interruption'); END");
|
||||
f.approve(sub);
|
||||
await assert.rejects(f.activate(sub));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0);
|
||||
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'prd-activate').length, 0);
|
||||
assert.notEqual(f.journal.readRequest('demo', f.request).phase, 'planning');
|
||||
db.exec('DROP TRIGGER reject_activation'); db.close();
|
||||
});
|
||||
test('owner draft read shows exact bound PRD and requirements without a generic file endpoint', async t => {
|
||||
const f = await setup(t), sub = await f.submit();
|
||||
const draft = await f.call('console', 'request.draft.console', { request: f.request });
|
||||
assert.deepEqual(draft.candidate, sub.candidate); assert.equal(draft.prd, '# Timer 1.0\nA timer.\n'); assert.equal(draft.requirements[0].id, 'REQ-POMO-1');
|
||||
for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' });
|
||||
});
|
||||
|
||||
test('3p to 3q migration preserves existing request rows and the append-only guards', async t => {
|
||||
const { readFileSync, chmodSync } = await import('node:fs');
|
||||
const { Store, schemaDigest } = await import('../src/store.mjs');
|
||||
const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(",'prd.submitted','prd.activated','prd.invalidated','mode.changed'", '');
|
||||
const db = new DatabaseSync(path); db.exec(ddl);
|
||||
const at = '2026-11-03T00:00:00.000Z';
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3p');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', at);
|
||||
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m',?,'demo','human','human','pm','INFO','Timer')").run(at);
|
||||
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r',?,'demo','human.input','m',?)").run(at, JSON.stringify({ kind: 'instruction', message: 'm' }));
|
||||
db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',1,'opened',?,'{}')").run(at);
|
||||
const before = db.prepare('SELECT * FROM request_events').all();
|
||||
assert.throws(() => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')").run(at));
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
|
||||
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
|
||||
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
|
||||
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',4,'prd.activated',?,'{}')", at), /revision-conflict/);
|
||||
});
|
||||
|
||||
test('guided Console approval uses the existing confirmation digest and attributed resolution evidence', async t => {
|
||||
const f = await setup(t, 'guided'), sub = await f.submit();
|
||||
const preview = f.call('console', 'decision.preview.console', { id: sub.decision.id });
|
||||
f.call('console', 'decision.resolve.console', { id: sub.decision.id, choice: 'yes', digest: preview.digest });
|
||||
const snap = await f.activate(sub); assert.equal(snap.approval.via, 'console');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE decision=? AND class='DECISION'", sub.decision.id).n, 1);
|
||||
});
|
||||
test('declined draft returns to discovery and mode change revokes an active snapshot', async t => {
|
||||
const f = await setup(t), sub = await f.submit();
|
||||
f.call('cto', 'decision.resolve', { id: sub.decision.id, choice: 'no' });
|
||||
const discovery = await f.bus.requestLifecycle.resume('demo', f.request);
|
||||
assert.equal(discovery.phase, 'discovery'); assert.equal(discovery.wait.code, 'prd-changes-requested');
|
||||
const next = await f.call('pm', 'prd.submit', { ...f.args, revision: discovery.revision, op: 'revision2' });
|
||||
f.approve(next); const snap = await f.activate(next);
|
||||
const state = f.journal.readRequest('demo', f.request);
|
||||
await f.call('console', 'request.mode.console', { request: f.request, revision: state.revision, mode_revision: state.mode_revision, mode: 'guided', op: 'mode2' });
|
||||
await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), { code: 'requirement-snapshot-revoked' });
|
||||
});
|
||||
@@ -274,7 +274,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
// Task 2's 3o→3p step widens only request_events.kind, so the 3o schema is the current one with the old list.
|
||||
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','cancelled'", "'setup.wait','cancelled'");
|
||||
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled'", "'setup.wait','cancelled'");
|
||||
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
|
||||
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
|
||||
const { schemaDigest, SCHEMA_VERSION } = awaitImportStore;
|
||||
@@ -286,7 +286,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3p ready\n'); assert.equal(SCHEMA_VERSION, '3p');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q ready\n'); assert.equal(SCHEMA_VERSION, '3q');
|
||||
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
|
||||
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
|
||||
@@ -307,7 +307,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite');
|
||||
// The 3o schema is the current one with the narrower kind list.
|
||||
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','cancelled'", "'setup.wait','cancelled'");
|
||||
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled'", "'setup.wait','cancelled'");
|
||||
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
|
||||
const db = new DatabaseSync(path); db.exec(ddl3o);
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o');
|
||||
@@ -323,7 +323,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3p ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
|
||||
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
|
||||
|
||||
@@ -525,6 +525,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
|
||||
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
|
||||
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
||||
// Task 4 (3p to 3q) widens request_events.kind in place; all 30 tables and 114 guards remain.
|
||||
assert.equal(count('table') - 1, 30);
|
||||
assert.equal(count('trigger'), 114);
|
||||
assert.equal(count('view'), 5);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -530,7 +530,7 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
const after = tables(path);
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
@@ -571,7 +571,7 @@ async function engine3m(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
|
||||
const Store3m = await engine3m(root);
|
||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
||||
const Store3k = await engine3k(root);
|
||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3p ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3q ready', ''].join('\n'));
|
||||
});
|
||||
@@ -403,7 +403,7 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
const after = tables(path);
|
||||
assert.deepEqual(after.thread_shares, []);
|
||||
delete after.thread_shares;
|
||||
@@ -439,7 +439,7 @@ async function engine3l(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
|
||||
const Store3l = await engine3l(root);
|
||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3p');
|
||||
assert.equal(version(path), '3q');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -261,3 +261,5 @@ export async function writeHumanFile({ path, content, base, ...context }) {
|
||||
}
|
||||
|
||||
export { readProjectFile, readSpec, listProjectFiles } from './read.mjs';
|
||||
|
||||
export { readPrdCandidate } from './prd.mjs';
|
||||
@@ -0,0 +1,55 @@
|
||||
// Private committed-blob access does not extend the public document editor's path vocabulary.
|
||||
import { ProjectError, ID } from './paths.mjs';
|
||||
import { openRevision, committedBlob, readSpec } from './read.mjs';
|
||||
import { git } from './git.mjs';
|
||||
import { validateRecord } from './validate.mjs';
|
||||
const fail = code => { throw new ProjectError(code, code); };
|
||||
const KEYS = ['business','project','dataRoot','request','prd_version','records_commit','prd_path','requirement_ids','mode_revision'];
|
||||
export async function readPrdMaterial(args) {
|
||||
if (!args || typeof args !== 'object' || KEYS.some(k => !Object.hasOwn(args, k)) || Object.keys(args).some(k => !KEYS.includes(k)) ||
|
||||
typeof args.request !== 'string' || !args.request.length || !/^\d+\.\d+(?:\.\d+)?$/.test(args.prd_version) ||
|
||||
args.prd_path !== `docs/prd-${args.prd_version}.md` || !Number.isSafeInteger(args.mode_revision) || args.mode_revision < 1 ||
|
||||
!Array.isArray(args.requirement_ids) || !args.requirement_ids.length || args.requirement_ids.length > 64 ||
|
||||
args.requirement_ids.some(id => typeof id !== 'string' || !ID.test(id)) || new Set(args.requirement_ids).size !== args.requirement_ids.length)
|
||||
fail('prd-candidate-invalid');
|
||||
const { records_commit, prd_path, requirement_ids, mode_revision, request, project, prd_version, ...context } = args;
|
||||
const ctx = { ...context, project }, opened = await openRevision({ ...ctx, commit: records_commit });
|
||||
const { repo, head } = opened;
|
||||
let total = 0;
|
||||
const selected = async path => {
|
||||
const item = await committedBlob(repo, records_commit, path, 256 * 1024);
|
||||
const current = await committedBlob(repo, head, path, 256 * 1024);
|
||||
if (item.blob !== current.blob) fail('prd-candidate-stale');
|
||||
total += item.bytes;
|
||||
if (total > 256 * 1024) fail('prd-candidate-size');
|
||||
return item;
|
||||
};
|
||||
const prd = await selected(prd_path);
|
||||
if (!prd.content.trim() || prd.bytes > 32768) fail('prd-candidate-size');
|
||||
const requirements = [], records = [], authors = [];
|
||||
const authored = async path => {
|
||||
const commit = await git(repo, ['log', '-1', '--format=%H', records_commit, '--', path]);
|
||||
const name = await git(repo, ['show', '-s', '--format=%an', commit]);
|
||||
const split = name.indexOf('.');
|
||||
if (split < 1 || !ID.test(name.slice(0, split)) || !ID.test(name.slice(split + 1))) fail('prd-author-invalid');
|
||||
const author = { definition: name.slice(0, split), instance: name.slice(split + 1), commit };
|
||||
if (!authors.some(a => a.commit === commit && a.instance === author.instance)) authors.push(author);
|
||||
};
|
||||
await authored(prd_path);
|
||||
for (const id of requirement_ids) {
|
||||
const path = `requirements/${id}.json`, item = await selected(path);
|
||||
let record; try { record = JSON.parse(item.content); } catch { fail('prd-requirement-invalid'); }
|
||||
if (validateRecord('requirement', record).length || record.id !== id || !record.title.trim() || !record.text.trim()) fail('prd-requirement-invalid');
|
||||
if (Buffer.byteLength(record.text) > 16384) fail('prd-candidate-size');
|
||||
requirements.push({ id, title: record.title, path, blob: item.blob }); records.push(record);
|
||||
await authored(path);
|
||||
}
|
||||
const spec = await readSpec(ctx), previousSpec = await readSpec({ ...ctx, commit: records_commit });
|
||||
if (!spec.confirmed || previousSpec.commit !== spec.commit) fail('stale-spec');
|
||||
// Detect a concurrent records write, even when it happened during the bounded reads.
|
||||
if (await git(repo, ['rev-parse', 'HEAD']) !== head) fail('prd-candidate-stale');
|
||||
const candidate = { version: 1, request, project, prd_version, records_commit, prd_path, prd_blob: prd.blob,
|
||||
requirements, spec_commit: spec.commit, mode_revision };
|
||||
return { candidate, prd: prd.content, requirements: records, authors };
|
||||
}
|
||||
export async function readPrdCandidate(args) { return (await readPrdMaterial(args)).candidate; }
|
||||
@@ -15,19 +15,22 @@ function text(bytes) {
|
||||
return value;
|
||||
} catch { fail('project-read-content', 'expected bounded UTF-8 without NUL'); }
|
||||
}
|
||||
async function blob(repo, revision, path) {
|
||||
export async function committedBlob(repo, revision, path, maxBytes = MAX_BYTES) {
|
||||
const entry = await git(repo, ['ls-tree', '-z', revision, '--', path]);
|
||||
if (!entry) fail('project-read-missing', 'file absent at revision');
|
||||
const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry);
|
||||
if (!match || match[3] !== path) fail('project-read-content', 'expected regular Git blob');
|
||||
const size = Number(await git(repo, ['cat-file', '-s', match[2]]));
|
||||
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES)
|
||||
if (!Number.isSafeInteger(size) || size < 0 || size > maxBytes)
|
||||
fail('project-read-content', 'blob exceeds size bound');
|
||||
return text(await gitBytes(repo, ['cat-file', 'blob', match[2]], MAX_BYTES));
|
||||
return { blob: match[2], bytes: size, content: text(await gitBytes(repo, ['cat-file', 'blob', match[2]], maxBytes)) };
|
||||
}
|
||||
async function blob(repo, revision, path) {
|
||||
return (await committedBlob(repo, revision, path)).content;
|
||||
}
|
||||
// The repository, its HEAD and the revision to read, with the committed project identity checked
|
||||
// against the caller's business and project. Shared by the file read and the file list.
|
||||
async function openRevision({ business, project, dataRoot, commit }) {
|
||||
export async function openRevision({ business, project, dataRoot, commit }) {
|
||||
const repo = projectRepo({ business, project, dataRoot });
|
||||
await inspectGit(repo);
|
||||
let head;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { initProject, writeRecord } from '../src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'jason', GIT_AUTHOR_EMAIL: '[email protected]', GIT_COMMITTER_NAME: 'jason', GIT_COMMITTER_EMAIL: '[email protected]' } }).trim();
|
||||
export async function prdFixture(t, context) {
|
||||
const root = context?.dataRoot ?? mkdtempSync(join(tmpdir(), 'prd-'));
|
||||
if (!context) t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
context ??= { dataRoot: root, project: 'timer', business: { id: 'demo', human: 'jason', projects: { timer: { root: join(root, 'code') } } } };
|
||||
const repo = context.business.projects[context.project].repo ?? join(context.dataRoot, 'projects', context.business.id, context.project);
|
||||
if (!existsSync(join(repo, '.git'))) await initProject(context);
|
||||
const save = (path, bytes, author = 'jason') => {
|
||||
writeFileSync(join(repo, path), bytes);
|
||||
git(repo, 'add', '--', path); git(repo, 'commit', '--author', `${author} <[email protected]>`, '-qm', 'Save ' + path);
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
if (git(repo, 'show', 'HEAD:spec/north-star.md') !== 'Build a timer.') save('spec/north-star.md', 'Build a timer.\n');
|
||||
const author = { definition: 'pm', instance: 'pm' };
|
||||
await writeRecord({ ...context, author, path: 'docs/prd-1.0.md', record: '# Timer 1.0\nA timer.\n' });
|
||||
await writeRecord({ ...context, author, path: 'requirements/REQ-POMO-1.json', record: { recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'A working timer.', status: 'draft' } });
|
||||
const args = { ...context, request: 'request-1', prd_version: '1.0', records_commit: git(repo, 'rev-parse', 'HEAD'), prd_path: 'docs/prd-1.0.md', requirement_ids: ['REQ-POMO-1'], mode_revision: 1 };
|
||||
return { context, repo, save, args };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as api from '../src/index.mjs';
|
||||
import { git, prdFixture } from './prd-fixture.mjs';
|
||||
|
||||
test('candidate reads exact committed blobs and tolerates unrelated HEAD commits', async t => {
|
||||
const f = await prdFixture(t), candidate = await api.readPrdCandidate(f.args);
|
||||
assert.equal(candidate.prd_blob, git(f.repo, 'rev-parse', `${f.args.records_commit}:docs/prd-1.0.md`));
|
||||
assert.equal(candidate.requirements[0].blob, git(f.repo, 'rev-parse', `${f.args.records_commit}:requirements/REQ-POMO-1.json`));
|
||||
assert.equal(candidate.spec_commit, git(f.repo, 'log', '-1', '--format=%H', '--', 'spec/north-star.md'));
|
||||
assert.deepEqual(Object.keys(candidate).sort(), ['version','request','project','prd_version','records_commit','prd_path','prd_blob','requirements','spec_commit','mode_revision'].sort());
|
||||
f.save('docs/unrelated.md', 'Unrelated');
|
||||
assert.deepEqual(await api.readPrdCandidate(f.args), candidate);
|
||||
});
|
||||
for (const path of ['docs/prd-1.0.md', 'requirements/REQ-POMO-1.json']) test(`changed selected blob is stale: ${path}`, async t => {
|
||||
const f = await prdFixture(t);
|
||||
f.save(path, path.endsWith('.md') ? 'Edited' : JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Edited', text: 'Changed', status: 'accepted' }));
|
||||
await assert.rejects(api.readPrdCandidate(f.args), { code: 'prd-candidate-stale' });
|
||||
});
|
||||
test('candidate rejects extra assertions, duplicate IDs, wrong version and unreachable commits', async t => {
|
||||
const f = await prdFixture(t);
|
||||
for (const patch of [{ prd_blob: 'forged' }, { requirement_ids: ['REQ-POMO-1','REQ-POMO-1'] }, { prd_version: '2.0' }, { records_commit: 'a'.repeat(40) }])
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, ...patch }));
|
||||
await assert.rejects(api.readProjectFile({ ...f.context, path: 'requirements/REQ-POMO-1.json' }), { code: 'project-read-path' });
|
||||
});
|
||||
test('candidate rejects malformed requirements, mismatched IDs and nonregular blobs', async t => {
|
||||
const f = await prdFixture(t);
|
||||
const original = JSON.parse(readFileSync(join(f.repo, 'requirements/REQ-POMO-1.json')));
|
||||
for (const record of [{ ...original, extra: true }, { ...original, id: 'OTHER' }, { ...original, title: ' ' }]) {
|
||||
const records_commit = f.save('requirements/REQ-POMO-1.json', JSON.stringify(record), 'pm.pm');
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, records_commit }));
|
||||
}
|
||||
rmSync(join(f.repo, 'docs/prd-1.0.md')); symlinkSync('../spec/north-star.md', join(f.repo, 'docs/prd-1.0.md'));
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Link');
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, records_commit: git(f.repo, 'rev-parse', 'HEAD') }));
|
||||
});
|
||||
test('candidate enforces 64 requirements, 32 KiB PRD, 16 KiB requirement and 256 KiB aggregate', async t => {
|
||||
const f = await prdFixture(t);
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, requirement_ids: Array.from({ length: 65 }, (_, i) => `R${i}`) }));
|
||||
let records_commit = f.save('docs/prd-1.0.md', 'x'.repeat(32769));
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, records_commit }), { code: 'prd-candidate-size' });
|
||||
f.save('docs/prd-1.0.md', 'x'.repeat(32768), 'pm.pm');
|
||||
const record = { recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'x'.repeat(16385), status: 'draft' };
|
||||
records_commit = f.save('requirements/REQ-POMO-1.json', JSON.stringify(record), 'pm.pm');
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, records_commit }), { code: 'prd-candidate-size' });
|
||||
const ids = Array.from({ length: 16 }, (_, i) => `R${i}`);
|
||||
for (const id of ids) f.save(`requirements/${id}.json`, JSON.stringify({ ...record, id, text: 'x'.repeat(16384) }), 'pm.pm');
|
||||
await assert.rejects(api.readPrdCandidate({ ...f.args, records_commit: git(f.repo, 'rev-parse', 'HEAD'), requirement_ids: ids }), { code: 'prd-candidate-size' });
|
||||
});
|
||||
+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