From 1904c9775e860f0dc085e313708e6ffaf6dd2ce1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 4 Nov 2026 12:54:04 -0600 Subject: [PATCH] feat(bus): record reviewed delivery and let the owner fetch the artifact (#1839) --- BUILD-LOG.md | 4 + docs/SESSIONS.md | 3 + packages/bus/src/broker.mjs | 28 +++- packages/bus/src/request-continuation.mjs | 6 +- packages/bus/src/request-lifecycle.mjs | 69 ++++++++- packages/bus/src/request-store.mjs | 2 + packages/bus/src/requirement-catalog.mjs | 4 +- packages/bus/src/runtime.mjs | 3 +- packages/bus/tests/channel-proof.test.mjs | 3 +- packages/bus/tests/console-decide.test.mjs | 6 +- packages/bus/tests/delivery-fixture.mjs | 91 ++++++++++++ packages/bus/tests/request-console.test.mjs | 9 +- .../bus/tests/request-continuation.test.mjs | 42 +++++- packages/bus/tests/request-delivery.test.mjs | 139 ++++++++++++++++++ packages/harness/src/tools.mjs | 4 + packages/harness/tests/bundle.test.mjs | 2 +- packages/harness/tests/tools.test.mjs | 2 +- packages/webui/src/bus.mjs | 2 +- packages/webui/src/public/requests.js | 14 +- packages/webui/src/request-http.mjs | 33 ++++- packages/webui/tests/artifact-http.test.mjs | 39 +++++ .../webui/tests/artifact-transport.test.mjs | 23 +++ packages/webui/tests/request-browser.test.mjs | 22 +++ 23 files changed, 513 insertions(+), 37 deletions(-) create mode 100644 packages/bus/tests/delivery-fixture.mjs create mode 100644 packages/bus/tests/request-delivery.test.mjs create mode 100644 packages/webui/tests/artifact-http.test.mjs create mode 100644 packages/webui/tests/artifact-transport.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 51284db10..3fb81105e 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6115,3 +6115,7 @@ runs were joined. Queue remains with the lead after the linked-worktree refusal. Local commit only; no push or live instance/credential-file access. 2026-11-04T17:21:41Z | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 lifecycle Task 6b before/after | Before: origin/refactor 103168614 includes Task 6a intake/staff tools and Task 5a create recovery; Task 4 source 76207fe48 cherry-picked as e41ec8159d23d52963d869abf22fc9d430831f44 with both append-only log sides retained. No checked task/review advancement or durable role continuation recovery existed. After: PM-only submit_prd and advance_request map to exact PRD submission and evidence-derived planning → implementation → review → delivery → complete transitions (changes return to implementation); task-create/assignment, latest project push/review/verdict and closure evidence are rechecked under the request revision and current snapshot. Eligible PM/CTO intake_status path reads expose only selected committed draft documents. Durable continuation intents are keyed by request/revision/target/step and composed atomically with outgoing messages; immutable attempt, manifest and exact launch-refusal receipts deduplicate retries. Message reads never acknowledge progress; validated lifecycle transitions and the chief's delivery effect in the originating assistant session do. Startup/event recovery plus a single-flight 30-second pass bounded to 64 requests catches missed wakeups. Successors receive the same unfinished key, with existing routed/effect receipts rechecked before resending. The launcher starts only the configured chief through its existing launchPm path; workers use that chief's real launch/end authority. Old product manifests drain their current turn, then the chief relaunches with request.admission and real ensureCheckouts. Exact refusals appear as RequestState.launch_refusal for intake-panel integration; no Console panel is present in this base. No synthetic capability, PM launch grant, hire through the empty chief hireable list, roles/*.json change, extra scheduler service, live-instance action, configuration/credential mount or new worker import was added. Six role duty documents updated. Migration 3q-3r is necessary solely to add the checked advanced lifecycle event to the closed request_events kind list; it preserves rows, sequence and all append-only guards, with historical fixture/version pins updated. Corrections: migration digest/pins and one bundle tool-list assertion were updated; notification scheduling was narrowed so reads do not cause unsolicited bootstrap retries; an accidental repository-root npm test invocation reported no test script and was corrected to packages/harness. One broad bus run hit the pre-existing #656 FIFO child niceness 16 vs 19 assertion (also recorded by Task 4); that test passed 21/21 in isolation and the final full bus rerun passed without a source change to it. Final GREEN, zero skips: new 43/43, focused compatibility 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36; extra sandbox import closure 11/11, git diff --check clean. Logs: /mnt/storage/scratch/tmp/task6b-final-new.log, task6b-final-focused.log, task6b-final-harness.log, task6b-final-bus.log, task6b-final-business.log, task6b-final-launcher.log and task6b-final-sandbox.log in the same directory; earlier failure/isolated logs retained. All test invocations used fresh TMPDIR directories from /mnt/storage/scratch/tmp/XXXXXX. Queue command refused this linked worktree, so no queue movement was attempted in the canonical checkout. One local implementation commit only; no push, merge, release, deployment or live-instance changes. 2026-11-04T17:57:52Z | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase before/after | Before: local 347791b55 on the superseded Task 4 cherry-pick e41ec8159; origin/refactor is b5a58b48b4c254f166135c24e22bd63ce4b5e0b9 with Task 4 c098fa022, #772 793f3b157, Task 3 and Task 5b. After: replay only 347791b55 on the explicitly requested new branch t3/lifecycle-task-6b-r2; e41ec8159 is dropped. Shipped Task 3 migration 3q-3r and all seventeen existing migration files are byte-identical to the base. New 3r-3s rebuild preserves all fifteen shipped event kinds (including question.asked, question.answered, attach.selected and every Task 4 PRD/mode kind) and adds advanced, for sixteen; schema.sql matches, SCHEMA_VERSION=3s, SCHEMA_SHA256=fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03. The migration test carries rows of every prior kind, preserves a sqlite_sequence high-water mark, index and every request/effect guard, then appends advanced at the next sequence. Current-version, offline-chain and synthetic future-step test pins updated through 3s (future fixture 3t). Conflicts resolved by keeping both behavior sets: broker retains Console owner/verb guards, request question/answer/attach/cancel handlers, Task 5b builtin binding and scoped requirements/git admission, plus 6b advance and continuation services; lifecycle exports the union of Task 3 and 6b operations; runtime retains Task 5b builtin binding and #772 pending-product readiness while adding 6b recovery; harness keeps Task 5b project/snapshot/op schemas and catalog prose while adding 6b tools, with duplicate schema properties removed; six role duty documents merged cleanly and retained; append-only logs keep both sides. Integration corrections: unanswered owner questions suspend new continuation dispatch, answer evidence resumes it, and planning continuations carry the stable create op required by Task 5b. A real Task 5b adapter test creates/assigns the task with that op, advances it and keeps cross-project git refused. First full bus run 1554/1555 found a Task 3 global-zero-effects assertion; it now asserts attach preserves the exact preexisting continuation receipts. Another bus run and isolated #656 control reproduced the existing nice 16 versus 19 race: the installed Node scheduling rule changes startup priority. The owned FIFO child now sets priority after fixture construction immediately before the measured operation; all priority/nonblocking assertions remain intact, no daemon or live instance changed. FIFO 21/21 and final full bus 1555/1555 pass. First WebUI run 537/540, two failures and one skip: missing local esbuild 0.28.2 and one browser refresh timeout; npm ci --ignore-scripts installed only the locked WebUI dependencies, the browser file passes 2/2 in isolation, and whole WebUI passes 540/540 with MOSAIC_REQUIRE_EDITOR_BUILD=1 and test-concurrency=4. An accidental root npm test invocation reported no script and ran no tests; corrected to the bus package. Final GREEN, zero skips/cancellations: new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540. Every test invocation used its own mktemp -d /mnt/storage/scratch/tmp/XXXXXX TMPDIR. Logs /mnt/storage/scratch/tmp/task6b-r2-new-final.log, task6b-r2-bus-final.log, task6b-r2-harness.log, task6b-r2-business.log, task6b-r2-launch.log, task6b-r2-webui-final.log; complete per-run counts in task6b-r2-runs.json and schema evidence in task6b-r2-schema-proof.json in the same directory. No unresolved conflict, role JSON authority change, shipped migration edit, credential/config mount, push, merge, deployment or live-instance action. Queue command again refused this linked worktree; canonical coordination is unchanged. Local rebase only, with no attribution trailers. + +2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b WIP | Implemented reviewed-delivery draft, 6b completion transition, owner attachment and legacy completion handling on t3/lifecycle-task-7b; no migration. Red-first logs and focused evidence under /mnt/storage/scratch/tmp/vWMFoL/. Brief trio 22/22, harness 228/228, business 307/307, seat 239/239, webui 558/558, zero skips on those runs. Whole bus 1566/1569: channel-proof freezes the changed webui transport bytes and two Console whitelist pins omit the mandated artifact verb. Each failing file reproduced alone and passed on 647626bdd. Stopped for lead ruling on the security proof baseline; tests and changes remain uncommitted, no push. + +2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b | Lead authorized the Console transport proof re-pin after the earlier stop: webui bus.mjs changes only the artifact response cap to 2 MiB, all other Console responses retain 1 MiB, and the focused transport test checks both boundaries and refusal above them. Added owner-only artifact read, reviewed blob and reviewer record.write receipt binding, atomic completion and chief intent, same-artifact duplicate handling, historical complete preservation, safe attachment and evidence panel. Real task.close through the tasks adapter and fake tracker now feeds an end-to-end delivery fixture; coder push_working and reviewer fetch_review use distinct local clones. The downloaded HTML was opened in offline Chromium and matched the reviewed blob and hash. No migration; schema remains 3s. GREEN zero skips: brief 24, bus 1570, harness 228, business 307, seat 239, webui 559; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Previous bus failure and stop remain recorded above; no push or deployment. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 4df571f35..415aa7707 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2013,3 +2013,6 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 Task 6b | Typed PRD/advance and scoped draft reads; durable continuations, evidence acknowledgements, chief launch refusal/retry and manifest drain/relaunch; migration 3q-3r. New 43/43, focused 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36, extra sandbox 11/11, zero skips; /mnt/storage/scratch/tmp/task6b-final-*.log. Task 4 cherry-pick e41ec8159 from 76207fe48; one local commit only, no push/live changes, queue left to canonical coordinator after linked-worktree refusal. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase onto b5a58b48b | Dropped e41ec8159; merged Task 3 controls/questions, Task 5b scope/op admission and 6b continuations. New migration 3r-3s retains all fifteen shipped kinds plus advanced; all shipped migrations unchanged. Added question/op integration checks and fixed owned FIFO priority setup timing without weakening assertions. Final new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540, zero skips; per-run counts/log paths /mnt/storage/scratch/tmp/task6b-r2-runs.json. Both append-only logs retained; local rebase only, no attribution trailers or push/live changes; queue remains with canonical coordinator after linked-worktree refusal. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r3 | #797 / Gitea #1839 Task 6b rebase onto 68bef84fa | Kept both append-only log sides, the Task 4 snapshot-counter behavior and the full 6b continuation changes; 3r-3s migration remains byte-identical to 65588327d. Gates, zero skips: bus 1556/1556, harness 228/228, business 307/307, webui 554/554; logs /mnt/storage/scratch/tmp/nj4n7n/gate-*.log. Local commit only, no push. +2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b preflight | Stopped before implementation: landed Task 6b advances delivery to complete on task closure and creates a chief send_message continuation before artifact evidence, conflicting with Task 7 delivery and atomic notification requirements; owner direction needed. No migration, tests, commit, push, or product changes. +2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b WIP | After lead ruling implemented and tested a delivery draft; stopped at frozen Console proof/transport hash conflict, with bus 1566/1569 and other recorded gates green; no migration, no commit, no push. Logs /mnt/storage/scratch/tmp/vWMFoL/. +2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b | Lead-approved proof re-pin, artifact-only cap, real adapter task.close fixture and completed delivery draft. Brief 24/24, bus 1570/1570, harness 228/228, business 307/307, seat 239/239, webui 559/559; zero skips, no migration; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Local commit only, no push. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 77f82fce3..6f008f382 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -160,15 +160,15 @@ 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', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', +export const CONSOLE_VERBS = Object.freeze(['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', // Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's // unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only // request.question and the PM's own reads keep their unsuffixed names and refuse a console cap. 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); // What an owner's console cap may ask of the request lifecycle (a read, the intake controls, and Task 4's mode and draft). -const INTAKE_CONSOLE = Object.freeze(['get', 'answer', 'attach', 'cancel', 'mode', 'draft']); +const INTAKE_CONSOLE = Object.freeze(['artifactConsole', 'get', 'answer', 'attach', 'cancel', 'mode', 'draft']); const REQUEST_VERBS = Object.freeze({ 'request.open': 'open', 'request.get': 'get', 'request.projects': 'projects', 'request.propose': 'propose', - 'prd.submit': 'submit', 'request.advance': 'advance', 'request.question': 'question', 'request.get.console': 'get', 'request.projects.console': 'projects', + 'prd.submit': 'submit', 'request.advance': 'advance', 'request.deliver': 'deliver', 'request.artifact.console': 'artifactConsole', 'request.question': 'question', 'request.get.console': 'get', 'request.projects.console': 'projects', 'request.answer.console': 'answer', 'request.attach.console': 'attach', 'request.cancel.console': 'cancel' }); // 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 @@ -612,6 +612,7 @@ export class Broker { dataRoot, builtinBinding: builtinFor, progress: state => this.#requestProgress(state), + deliveryEvidence: (state, args) => this.#deliveryEvidence(state, args), continuationContext: (state, target) => this.#continuationContext(state, target), continuationEffect: (state, intent, message) => { if (intent.step !== 'send_message' || state.phase !== 'complete' || !state.thread) return null; @@ -767,6 +768,25 @@ export class Broker { if (!phase) fail('request-evidence-required'); return { phase, tasks, evidence: [...new Set(evidence)] }; } + #deliveryEvidence(state, args) { + const progress = this.#requestProgress(state); + if (state.phase !== 'delivery' || !state.tasks.includes(args.task_ref)) fail('request-evidence-required'); + const rows = this.#store.all('SELECT * FROM events WHERE business=? AND subject=? ORDER BY seq', state.business, args.task_ref) + .map(e => ({ ...e, body: JSON.parse(e.body) })); + const push = rows.findLast(e => e.kind === 'action.allowed' && e.body.action === 'git.push.working'); + const review = rows.findLast(e => e.kind === 'review.requested' && e.seq > push.seq && e.body.commit === push.body.commit && e.body.project === state.project); + const verdict = review && rows.findLast(e => e.kind === 'review.verdict' && e.body.request === review.id && e.body.commit === push.body.commit); + if (!review || !verdict || verdict.id !== args.verdict || verdict.body.verdict !== 'approve' || verdict.actor_role !== review.body.to || verdict.actor_role === push.actor_role) fail('request-evidence-required'); + const receipt = this.#store.all("SELECT * FROM events WHERE business=? AND kind='action.allowed' AND actor_role=? AND seq { const b = JSON.parse(e.body); return e.seq > review.seq && b.project === state.project && b.path === args.verification_record.path && b.commit === args.verification_record.commit; }); + if (!receipt) fail('request-verification-refused'); + const message = this.#store.get('SELECT body FROM messages WHERE business=? AND id=?', state.business, verdict.body.message); + const marker = `Review verdict ${verdict.id} on ${args.task_ref}, commit ${push.body.commit}: approve. A task_close cites this verdict id.\n\n`; + if (!message?.body.startsWith(marker)) fail('request-verification-refused'); + let report; try { report = JSON.parse(message.body.slice(marker.length)); } catch { fail('request-verification-refused'); } + if (!object(report) || Object.keys(report).sort().join(',') !== 'results,verification_record' || !object(report.verification_record) || Object.keys(report.verification_record).sort().join(',') !== 'commit,path' || report.verification_record.path !== args.verification_record.path || report.verification_record.commit !== args.verification_record.commit || typeof report.results !== 'string' || !report.results.trim()) fail('request-verification-refused'); + return { commit: push.body.commit, review_request: review.id, verdict: verdict.id, results: report.results, evidence: progress.evidence, receipt: receipt.id }; + } #requestAuthorize(s, state, operation) { if (!s || s.business !== state.business) fail('request-access-refused'); if (s.reader) fail('read-only'); @@ -779,7 +799,7 @@ export class Broker { if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused'); return; } - if (['mode', 'draft'].includes(operation)) fail('human-required'); + if (['mode', 'draft', 'artifactConsole'].includes(operation)) fail('human-required'); if (s.human) { this.#human(s); if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused'); diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index c0a05d25c..15499da91 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -36,7 +36,7 @@ export function createContinuations({ journal, services }) { return []; } const evidence = state => ({ source_message: state.source_message, decision: state.wait?.kind === 'decision' ? state.wait.subject : null, - question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, events: state.evidence ?? [] }); + question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, delivery: state.delivery_evidence ?? null, events: state.evidence ?? [] }); const intentFor = (state, target, step) => ({ key: digest([state.request, state.revision, target, step]), request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) }); function send(state, intent, context) { @@ -58,9 +58,9 @@ export function createContinuations({ journal, services }) { resolve_decision: 'Read the exact PRD candidate and requirements with intake_status(path), one selected path at a time. Resolve only the cited PRD decision through resolve_decision; ask for changes if evidence is incomplete.', task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Create and assign the scoped tasks, request_staff for coder/reviewer sessions, then advance_request.', push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements, push_working, and request_review for each latest commit.', - review_verdict: 'Fetch each current review request with fetch_review, review the artifacts and return review_verdict against that exact request and commit.', + review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, write a verification Markdown record containing a JSON object with version 1, task_ref, review_request, commit and results (a nonempty string), then return review_verdict with a JSON body containing exactly verification_record: {path, commit} from write_record and results, matching the record.', advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.', - task_close: 'Close each task with task_close citing the latest approve verdict. Verify the completed artifacts, then advance_request.', + task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record; closing alone does not complete delivery.', send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]); return services.continuationMessage(state, intent.target, body, context); } diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index c342e27a1..c862d522e 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -4,6 +4,8 @@ // Later executors must consume these exact immutable bindings, never approvalChoice. import { createContinuations } from './request-continuation.mjs'; import { lstatSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename } from 'node:path'; import { join, dirname } from 'node:path'; import { exact, object, id, key, text, digest, validatePlan, refuse, positive, optionList, QUESTION_PHASES, PICKER_CODES } from './request-store.mjs'; @@ -271,13 +273,14 @@ export function createRequestLifecycle({ journal, services }) { 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 (['cancelled','delivered','complete'].includes(state.phase)) refuse('request-phase-refused'); 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 }); + if (attached.project === state.project && !['cancelled','delivered','complete'].includes(attached.phase)) append(attached, 'mode.changed', { mode: args.mode, mode_revision, op: args.op, proposal_digest }); } return stateOf(state.business, state.request); })); @@ -333,7 +336,7 @@ export function createRequestLifecycle({ journal, services }) { if (old && old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return old !== null; }; - const ended = state => { if (['cancelled', 'delivered'].includes(state.phase)) refuse('request-phase-refused'); }; + const ended = state => { if (['cancelled', 'delivered', 'complete'].includes(state.phase)) refuse('request-phase-refused'); }; // The PM's one outstanding question. It is a journal event, not a decision: an answer is text and approves nothing. async function question(identity, args) { exact(args, ['request','revision','op','text'], ['options']); @@ -420,6 +423,7 @@ export function createRequestLifecycle({ journal, services }) { 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.phase === 'delivery') { services.progress(state); refuse('request-delivery-required'); } if (!state.snapshot || !services.progress) refuse('request-evidence-required'); await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); return journal.transaction(tx => { @@ -433,7 +437,66 @@ export function createRequestLifecycle({ journal, services }) { }); }); } - return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, advance, modeConsole, + async function deliver(identity, args) { + exact(args, ['request','revision','op','task_ref','verdict','path','verification_record']); + exact(args.verification_record, ['path','commit']); + if (!id(args.request) || !id(args.op) || !id(args.verdict) || !text(args.task_ref, 160) || !text(args.path, 4096) || + !text(args.verification_record.path, 256) || !/^[a-f0-9]{40}$/.test(args.verification_record.commit) || !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, 'deliver'); + const proposal = digest(args), old = journal.effects(state.business, state.request).filter(e => e.kind === 'delivery'); + const committed = old.find(e => e.stage === 'committed'); + if (committed) { + if (committed.op === args.op && committed.digest !== proposal) refuse('request-operation-conflict'); + const d = committed.result; + if (d.task_ref !== args.task_ref || d.verdict !== args.verdict || d.artifact.path !== args.path || d.verification.record_path !== args.verification_record.path || d.verification.record_commit !== args.verification_record.commit) refuse('request-operation-conflict'); + return d; + } + if (state.revision !== args.revision) refuse('request-revision-conflict'); + if (state.phase !== 'delivery') refuse('request-phase-refused'); + const header = { version: 1, business: state.business, request: state.request, op: args.op, kind: 'delivery', digest: proposal }; + journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null }); + const evidence = services.deliveryEvidence(state, args); + const [verification, artifact] = await Promise.all([services.readVerification(state, args.verification_record), services.readReviewedArtifact(state, evidence.commit, args.path)]); + let record; try { record = JSON.parse(verification.content); } catch { refuse('request-verification-refused'); } + exact(record, ['version','task_ref','review_request','commit','results'], [], 'request-verification-refused'); + if (verification.commit !== args.verification_record.commit || record.version !== 1 || record.task_ref !== args.task_ref || record.review_request !== evidence.review_request || record.commit !== evidence.commit || record.results !== evidence.results || !text(record.results)) refuse('request-verification-refused'); + if (artifact.mime !== 'text/html') refuse('artifact-type-refused'); + await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + const filename = basename(args.path); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) refuse('artifact-path-invalid'); + return journal.transaction(tx => { + state = stateOf(state.business, state.request); authorize(identity, state, 'deliver'); + if (state.revision !== args.revision || state.phase !== 'delivery') refuse('request-revision-conflict'); + const again = services.deliveryEvidence(state, args); + if (digest(again) !== digest(evidence)) refuse('request-evidence-required'); + const value = { version: 1, id: randomUUID(), business: state.business, request: state.request, project: state.project, snapshot: state.snapshot, + task_ref: args.task_ref, commit: evidence.commit, review_request: evidence.review_request, verdict: evidence.verdict, + artifact: { path: args.path, blob: artifact.blob, sha256: artifact.sha256, bytes: artifact.bytes.length, mime: artifact.mime }, + verification: { record_path: args.verification_record.path, record_commit: args.verification_record.commit }, message: null }; + const result = append(state, 'advanced', { op: args.op, proposal_digest: proposal, from: 'delivery', phase: 'complete', tasks: state.tasks, evidence: evidence.evidence }, identity, tx.appendRequest); + continuations.resume({ ...result, delivery: value.id, delivery_evidence: value }); + const notice = journal.effects(state.business, state.request).find(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.phase === 'complete' && e.result.intent.revision === result.revision && e.result.intent.step === 'send_message'); + if (!notice?.result?.message) refuse('request-notification-unavailable'); + value.message = notice.result.message; + services.record?.(identity, 'request.deliver', result); + journal.finishEffect({ ...header, stage: 'committed', result: value, code: null }); + return value; + }); + }); + } + async function artifactConsole(identity, args) { + exact(args, ['request','delivery']); + if (!id(args.request) || !id(args.delivery)) refuse('invalid-request'); + const state = stateOf(identity.business, args.request); authorize(identity, state, 'artifactConsole'); + const d = state.delivery_evidence; + if (!d || d.id !== args.delivery) refuse('request-not-found'); + const artifact = await services.readReviewedArtifact(state, d.commit, d.artifact.path); + const current = stateOf(identity.business, args.request); authorize(identity, current, 'artifactConsole'); + if (current.delivery !== d.id || artifact.sha256 !== d.artifact.sha256 || artifact.blob !== d.artifact.blob || artifact.bytes.length !== d.artifact.bytes) refuse('request-evidence-required'); + return { mime: d.artifact.mime, filename: basename(d.artifact.path), base64: artifact.bytes.toString('base64'), sha256: artifact.sha256 }; + } + return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, advance, deliver, artifactConsole, modeConsole, hasWork: () => journal.listRequests().length > 0, launches: continuations.launches, launchResult: continuations.launchResult, manifestNeeded: continuations.manifestNeeded, draftConsole: async (identity, args) => { exact(args, ['request']); if (!id(args.request)) refuse('invalid-request'); diff --git a/packages/bus/src/request-store.mjs b/packages/bus/src/request-store.mjs index bf4b5ac13..c89e1b05a 100644 --- a/packages/bus/src/request-store.mjs +++ b/packages/bus/src/request-store.mjs @@ -214,6 +214,8 @@ export function createRequestStore(store) { const latest = new Map(); for (const launch of launches) if (active.has(launch.key)) latest.set(launch.instance, launch); const last = launches.findLast(launch => launch.code && latest.get(launch.instance) === launch); + const delivery = store.get("SELECT result FROM request_effects WHERE business=? AND request=? AND kind='delivery' AND stage='committed' ORDER BY seq LIMIT 1", business, request); + if (delivery && state.phase === 'complete') { const value = JSON.parse(delivery.result); state.delivery = value.id; state.delivery_evidence = value; } state.launch_refusal = last ? { instance: last.instance, code: last.code, key: last.key } : null; return state; }; diff --git a/packages/bus/src/requirement-catalog.mjs b/packages/bus/src/requirement-catalog.mjs index 3ddf9f0b7..b7821ef36 100644 --- a/packages/bus/src/requirement-catalog.mjs +++ b/packages/bus/src/requirement-catalog.mjs @@ -11,7 +11,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding }) 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 }); + if (state.project === project && !['complete','delivered','cancelled'].includes(state.phase) && (state.candidate || state.snapshot)) append(state, 'prd.invalidated', { code }); } }); } @@ -91,7 +91,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding }) 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' }); + if (row.business === identity.business && state.project === args.project && !['complete','delivered','cancelled'].includes(state.phase) && state.snapshot === snap.id) append(state, 'prd.invalidated', { code: 'requirement-snapshot-revoked' }); } return result; }); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 409591206..434a50006 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { Store } from './store.mjs'; import { createRequestStore } from './request-store.mjs'; import { createRequestLifecycle } from './request-lifecycle.mjs'; +import { readReviewedArtifact } from '../../seat/src/artifact.mjs'; import { executeBootstrap } from './request-bootstrap.mjs'; import { hostPreparation } from './request-host.mjs'; import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs'; @@ -594,7 +595,7 @@ export async function startBroker({ const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval }, readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }), builtinBinding: services.builtinBinding }); - return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services, + return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(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, diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index 63207b57c..02098242e 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -34,7 +34,8 @@ const baselines = { // Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged. // Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets // revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change. - "packages/webui/src/bus.mjs": "4ebf6c2b5d71c23d85cbde83f69aa671058dc2195c9ae4bdda624f6940729d1b", + // #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged. + "packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692", }; const repoFile = path => new URL("../../../" + path, import.meta.url); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index 53ed21516..183fc3758 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -86,10 +86,10 @@ function setup(t) { return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant }; } -// #797 / #1839 Task 3 appends the five request-intake console verbs to Task 4's five and only those: the decision -// pair stays first and unchanged, and no unsuffixed verb is ever a console verb. +// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls; +// no unsuffixed verb is ever a console verb. 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.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console'))); assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false); diff --git a/packages/bus/tests/delivery-fixture.mjs b/packages/bus/tests/delivery-fixture.mjs new file mode 100644 index 000000000..d2fa6b454 --- /dev/null +++ b/packages/bus/tests/delivery-fixture.mjs @@ -0,0 +1,91 @@ +import { createGitSocket } from '../../cli/src/git-socket.mjs'; +import { pushWorking, fetchReview } from '../../harness/src/git-tools.mjs'; +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { git } from '../../project/tests/prd-fixture.mjs'; +import assert from 'node:assert/strict'; +import { requestFixture } from './request-fixture.mjs'; +import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs'; +import { DatabaseSync } from 'node:sqlite'; +import { createRequestStore } from '../src/request-store.mjs'; +const advance = async (f, op = 'next') => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); }; + +export async function planned(t, threaded = false) { + const f = await requestFixture(t, 'autonomous', null, threaded), sub = await f.submit(); f.approve(sub); await f.activate(sub); + const { createDigest, createMarker } = await import('../src/broker.mjs'); + const s = f.journal.readRequest('demo', f.request), task_ref = 'vikunja:2/41'; + const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: s.snapshot, + project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] }; + prepared.body = { title: 'Timer', description: createMarker(prepared) }; + prepared.body_digest = createDigest(prepared.body); + const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared); + let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 }; + const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() }); + f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } }); + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [] }); + await assert.rejects(advance(f), /request-evidence-required/); + fields = { ...fields, assignees: [5] }; + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] }); + const push = commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null }); + const review = commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' }); + // Lightweight continuation tests simulate the tracker receipt; plannedWithRealClose uses the actual adapter. + return { ...f, task_ref, push, review, closeTask: verdict => { + fields = { ...fields, done: true, bucket: 3 }; + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.closed', subject: task_ref, body: { verdict } }] }); + } }; +} + + +async function plannedWithRealClose(t) { + const f = await scopedFixture(t), product = await f.product('timer'); + const task = await f.create(product.args), task_ref = task.task_ref; + await f.bus.broker.requestTask(f.caps.pm, { verb: 'task.assign', args: { task_ref, role: 'coder' } }, f.adapter.handle); + const db = new DatabaseSync(join(f.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) }; + return { ...f, request: product.request, task_ref, store, journal: createRequestStore(store), + push: commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null }), + review: commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' }), + closeTask: verdict => f.bus.broker.requestTask(f.caps.pm, { verb: 'task.close', args: { task_ref, verdict } }, f.adapter.handle) }; +} + +export async function ready(t, options = {}) { + const f = options.realClose ? await plannedWithRealClose(t) : await planned(t, true); + const advance = async op => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); }; + await advance('start'); + const code = join(f.f.dataRoot, 'products/demo/timer/code'); + const sandbox = join(f.f.dataRoot, 'test-workspaces'); + const coder = join(sandbox, 'coder'), reviewer = join(sandbox, 'reviewer'), temp = join(sandbox, 'tmp'); + for (const dir of [coder, reviewer, temp, join(f.f.dataRoot, 'bus-host')]) mkdirSync(dir, { recursive: true }); + git(coder, 'clone', '-q', code, 'timer'); git(reviewer, 'clone', '-q', code, 'timer'); + const clone = join(coder, 'timer'); git(clone, 'checkout', '-qb', 'feat'); + const bytes = Buffer.from(`TimerReady`); + writeFileSync(join(clone, 'timer.html'), bytes); + if (options.invalidPaths) { symlinkSync('timer.html', join(clone, 'link.html')); writeFileSync(join(clone, 'large.html'), Buffer.alloc(1024 * 1024 + 1, 65)); } + git(clone, 'add', '-A'); git(clone, 'commit', '-qm', 'Build timer'); + const commit = git(clone, 'rev-parse', 'HEAD'); + const host = { op: async r => { + if (r.op === 'identity') return f.bus.identity(r.cap); + if (r.op === 'checkPush') return f.bus.checkPush(r.cap, r.task_ref, r.project); + if (r.op === 'recordPush') return f.bus.recordPush(r.cap, r.push); + if (r.op === 'reviewFetch') return f.bus.reviewFetch(r.cap, r.args); + if (r.op === 'refuse') return; + throw new Error('unexpected socket operation'); + } }; + const socket = createGitSocket({ host, business: { id: 'demo', projects: { timer: { root: code } } }, dataRoot: f.f.dataRoot, env: f.f.env }); + await socket.listen(); t.after(() => socket.close()); + const pushed = await pushWorking({ path: socket.path, cap: f.caps.coder, workspace: coder, task_ref: f.task_ref, project: 'timer', branch: 'feat', tmp: temp, env: f.f.env }); + assert.equal(pushed.commit, commit); + const review = f.review(commit); await advance('review'); + const fetched = await fetchReview({ path: socket.path, cap: f.caps.reviewer, workspace: reviewer, task_ref: f.task_ref, request: review.request, tmp: temp, env: f.f.env }); + assert.equal(fetched.commit, commit); + assert.equal(git(fetched.clone, 'show', `${fetched.commit}:timer.html`), bytes.toString()); + const results = 'Acceptance: timer starts and stops offline; passed.'; + const record = JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: review.request, commit, results }); + const verification = await f.call('reviewer', 'record.write', { project: 'timer', path: 'docs/verification.md', record }); + const verdictBody = JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results: options.results ?? results }); + const verdict = f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: review.request, verdict: 'approve', body: verdictBody }); + await advance('delivery'); await f.closeTask(verdict.verdict); + const s = f.journal.readRequest('demo', f.request); + const args = { request: f.request, revision: s.revision, op: 'deliver-1', task_ref: f.task_ref, verdict: verdict.verdict, path: 'timer.html', verification_record: { path: verification.path, commit: verification.commit } }; + return { ...f, advance, bytes, commit, review, verdict, verification, args }; +} diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index f6353b6a7..75d998427 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -89,21 +89,22 @@ async function atDiscovery(t) { const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request: state.request, revision: state.revision, op: 'q-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra }); -test('the console verb list is Task 4 five plus the five intake verbs, in that order', () => { - assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', +test('the console verb list includes the artifact read and existing owner controls', () => { + assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); }); test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => { const x = await atSelection(t), request = x.source.request; - const args = { 'request.get.console': { request }, 'request.projects.console': { request }, + const args = { 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request }, 'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' }, 'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' }, 'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' }, 'request.cancel.console': { request, revision: x.state.revision, op: 'o4' } }; for (const who of ['human', 'pm', 'cto', 'coder', 'otherpm', 'reader', 'tester']) for (const [verb, a] of Object.entries(args)) await assert.rejects(async () => x.call(who, verb, a), /human-required|read-only|tester-refused/, `${who} ${verb}`); - for (const verb of ['request.get', 'request.projects', 'request.propose', 'request.question', 'request.open']) + await assert.rejects(async () => x.call('console', 'request.artifact.console', { request, delivery: 'not-a-delivery' }), /request-not-found/); + for (const verb of ['request.deliver', 'request.get', 'request.projects', 'request.propose', 'request.question', 'request.open']) await assert.rejects(async () => x.call('console', verb, { request }), /human-required/, verb); assert.throws(() => x.b.bindConsole({ business: 'demo', human: 'someone', subject: SUBJECT }), /human-required/); assert.equal(n(x, 'SELECT count(*) n FROM request_events'), 2, 'opened and selection.proposed only'); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index c9a0d9781..042337251 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { requestFixture } from './request-fixture.mjs'; +import { ready } from './delivery-fixture.mjs'; const intents = f => f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent); const messages = (f, key) => f.store.all("SELECT * FROM messages WHERE from_role='_bus' AND body LIKE ?", `%${key}%`); @@ -100,7 +101,7 @@ async function planned(t, threaded = false) { } }; } -test('fixed advance derives implementation, review, delivery and completion from actual scoped receipts', async t => { +test('fixed advance derives implementation, review and delivery, but closure cannot complete delivery', async t => { const f = await planned(t); const initial = f.journal.readRequest('demo', f.request); const args = { request: f.request, revision: initial.revision, op: 'start' }; @@ -118,7 +119,8 @@ test('fixed advance derives implementation, review, delivery and completion from assert.equal((await advance(f, 'delivery')).phase, 'delivery'); await assert.rejects(advance(f, 'before-close'), /request-evidence-required/); f.closeTask(verdict.verdict); - assert.equal((await advance(f, 'complete')).phase, 'complete'); + await assert.rejects(advance(f, 'complete'), /request-delivery-required/); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); }); test('a newer push cannot borrow an older approved verdict; changes resume implementation', async t => { @@ -231,15 +233,16 @@ test('eligible CTO reads one exact draft document before tasks; arbitrary paths }); test('completion is acknowledged by the chief delivery effect in the original assistant session', async t => { - const f = await planned(t, true); await advance(f, 'start'); - const commit = 'b'.repeat(40); f.push(commit); const requested = f.review(commit); await advance(f, 'review'); - const v = f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: requested.request, verdict: 'approve', body: 'Artifact complete' }); - await advance(f, 'delivery'); f.closeTask(v.verdict); await advance(f, 'complete'); + const f = await ready(t); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); + assert.equal(intents(f).filter(e => e.result.intent.step === 'send_message').length, 0); + await f.call('pm', 'request.deliver', f.args); + assert.equal(intents(f).filter(e => e.result.intent.step === 'send_message').length, 1); const intent = intents(f).findLast(e => e.result.intent.step === 'send_message'); const read = f.call('chief', 'message.receive'); for (const m of read) f.call('chief', 'message.read', { id: m.id }); await f.bus.requestLifecycle.recover(); assert.ok(!f.journal.effects('demo', f.request).some(e => e.op === intent.op && e.stage === 'committed')); - const delivered = f.call('chief', 'message.send', { to: 'assistant', in_reply_to: f.source.id, body: `Timer artifact: project timer, ${f.task_ref}, approved verdict ${v.verdict}` }); + const delivered = f.call('chief', 'message.send', { to: 'assistant', in_reply_to: f.source.id, body: `Timer artifact: project timer, ${f.task_ref}, approved verdict ${f.verdict.verdict}` }); await f.bus.requestLifecycle.recover(); const acknowledged = f.journal.effects('demo', f.request).find(e => e.op === intent.op && e.stage === 'committed'); assert.equal(acknowledged.result.evidence.effect.message, delivered.id); @@ -288,3 +291,28 @@ test('Task 5b real scoped task creation and assignment feed the checked advance assert.throws(() => f.bus.checkPush(f.caps.coder, task.task_ref, 'stack'), { code: 'task-project-refused' }); assert.doesNotThrow(() => f.bus.checkPush(f.caps.coder, task.task_ref, 'timer')); }); + +test('historical complete event remains readable without delivery evidence and creates no second chief intent', async t => { + const f = await ready(t); + const { DatabaseSync } = await import('node:sqlite'); + const { join } = await import('node:path'); + const { digest } = await import('../src/request-store.mjs'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + const state = f.journal.readRequest('demo', f.request); + const evidence = f.store.all('SELECT id FROM events WHERE business=? AND subject=? ORDER BY seq', 'demo', f.task_ref).map(e => e.id); + const body = { op: 'legacy-complete', proposal_digest: digest('legacy'), from: 'delivery', phase: 'complete', tasks: state.tasks, evidence }; + db.prepare("INSERT INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body) VALUES(?,?,?,'advanced','pm','pm-run',?,?)").run('demo', f.request, state.revision + 1, new Date().toISOString(), JSON.stringify(body)); + await f.bus.requestLifecycle.recover(); + const before = f.journal.readRequest('demo', f.request); + assert.equal(before.phase, 'complete'); assert.equal(before.delivery, null); + const first = intents(f).filter(e => e.result.intent.step === 'send_message'); + assert.equal(first.length, 1); + await f.bus.requestLifecycle.recover(); + assert.equal(intents(f).filter(e => e.result.intent.step === 'send_message').length, 1); + assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery').length, 0); + await assert.rejects(f.call('console', 'request.cancel.console', { request: f.request, revision: before.revision, op: 'cancel-complete' }), /request-phase-refused/); + const snapshotRevision = f.journal.snapshotEvents('demo', 'timer').at(-1).revision; + await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: before.snapshot, revision: snapshotRevision, reason: 'Later decision', op: 'revoke-complete' }); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete'); + assert.equal(f.journal.readRequest('demo', f.request).revision, before.revision); +}); diff --git a/packages/bus/tests/request-delivery.test.mjs b/packages/bus/tests/request-delivery.test.mjs new file mode 100644 index 000000000..727784695 --- /dev/null +++ b/packages/bus/tests/request-delivery.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { requestFixture } from './request-fixture.mjs'; + +test('delivery requires the delivery phase even with a valid typed request', async t => { + const f = await requestFixture(t); + const s = f.journal.readRequest('demo', f.request); + await assert.rejects(Promise.resolve().then(() => f.call('pm', 'request.deliver', { request: f.request, revision: s.revision, op: 'delivery-1', task_ref: 'vikunja:2/41', verdict: 'verdict-1', path: 'timer.html', verification_record: { path: 'docs/verification.md', commit: 'a'.repeat(40) } })), /request-phase-refused/); +}); + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { git } from '../../project/tests/prd-fixture.mjs'; +import { ready } from './delivery-fixture.mjs'; + + +test('approved delivery binds exact blob, verification, task and review, and retries once', async t => { + const f = await ready(t); + const outcome = await f.call('pm', 'request.deliver', f.args); + assert.equal(outcome.commit, f.commit); assert.equal(outcome.task_ref, f.task_ref); + assert.equal(outcome.review_request, f.review.request); assert.equal(outcome.verdict, f.verdict.verdict); + assert.equal(outcome.artifact.blob, git(join(f.f.dataRoot, 'working/demo/timer.git'), 'rev-parse', `${f.commit}:timer.html`)); + assert.equal(outcome.artifact.sha256, createHash('sha256').update(f.bytes).digest('hex')); + assert.equal(outcome.verification.record_commit, f.verification.commit); + assert.deepEqual(await f.call('pm', 'request.deliver', f.args), outcome); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete'); + assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 1); + assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 1); + const artifact = await f.call('console', 'request.artifact.console', { request: f.request, delivery: outcome.id }); + assert.deepEqual(Buffer.from(artifact.base64, 'base64'), f.bytes); +}); + +test('a different operation for the same reviewed artifact returns the original delivery', async t => { + const f = await ready(t); const first = await f.call('pm', 'request.deliver', f.args); + const again = await f.call('pm', 'request.deliver', { ...f.args, op: 'retry-different-op', revision: f.args.revision + 1 }); + assert.deepEqual(again, first); + assert.equal(f.store.get("SELECT count(*) n FROM request_events WHERE request=? AND kind='advanced' AND json_extract(body,'$.phase')='complete'", f.request).n, 1); +}); + +test('a newer push or wrong task cannot borrow an earlier verdict', async t => { + const f = await ready(t); + await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, op: 'wrong-task', task_ref: 'vikunja:2/42' }), /request-evidence-required/); + f.push('c'.repeat(40)); + await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-evidence-required/); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); +}); + +test('a forged verification record cannot borrow the reviewer receipt', async t => { + const f = await ready(t); + const fake = await f.call('pm', 'record.write', { project: 'timer', path: 'docs/forged.md', record: JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: f.review.request, commit: f.commit, results: 'Acceptance: timer starts and stops offline; passed.' }) }); + await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, verification_record: { path: fake.path, commit: fake.commit } }), /request-verification-refused/); +}); + +test('delivery and chief intent roll back together when the notification cannot be recorded', async t => { + const f = await ready(t); + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + db.exec("CREATE TRIGGER fail_delivery_notice BEFORE INSERT ON messages WHEN NEW.from_role='_bus' AND NEW.to_role='chief' AND NEW.body LIKE 'Request continuation %' BEGIN SELECT RAISE(ABORT,'delivery-notice-failed'); END"); + await assert.rejects(f.call('pm', 'request.deliver', f.args), /delivery-notice-failed|request-failed|ERR_SQLITE_ERROR/); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); + assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0); + db.exec('DROP TRIGGER fail_delivery_notice'); + const delivered = await f.call('pm', 'request.deliver', f.args); + const intent = f.journal.effects('demo', f.request).find(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message'); + assert.equal(delivered.message, intent.result.message); +}); + + +test('verification results must equal those cited in the review verdict', async t => { + const f = await ready(t, { results: 'Different results' }); + await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-verification-refused/); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); +}); + +test('reviewed downloaded HTML opens offline and matches the committed blob', async t => { + const f = await ready(t); const d = await f.call('pm', 'request.deliver', f.args); + const { requestHTTP } = await import('../../webui/src/request-http.mjs'); + const { browser } = await import('../../webui/tests/browser.mjs'); + const { pathToFileURL } = await import('node:url'); + const session = { name: 'jason', provider: 'test', iss: 'test', sub: 'owner' }; + const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } }; + await requestHTTP({ method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } }, res, { + path: `/api/requests/${f.request}/artifact/${d.id}`, session, live: () => session, origin: 'https://console.test', log() {}, requestWrite: null, + requestRead: (verb, args) => f.call('console', verb, args), + }); + assert.equal(res.status, 200); assert.deepEqual(res.data, f.bytes); + const downloaded = join(f.f.dataRoot, 'downloaded-timer.html'); writeFileSync(downloaded, res.data); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.call('Network.enable'); + await b.call('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }); + await b.navigate(pathToFileURL(downloaded).href); + assert.equal(await b.evaluate('navigator.onLine'), false); + assert.equal(await b.evaluate("document.querySelector('#start').click(); document.querySelector('#clock').textContent"), 'Running'); + assert.equal(createHash('sha256').update(res.data).digest('hex'), d.artifact.sha256); + assert.equal(git(join(f.f.dataRoot, 'working/demo/timer.git'), 'rev-parse', `${d.commit}:timer.html`), d.artifact.blob); +}); + +test('a later changes verdict cannot deliver the previous approval', async t => { + const f = await ready(t); const review = f.call('coder', 'review.request', { task_ref: f.task_ref, commit: f.commit, to: 'reviewer' }); + f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: review.request, verdict: 'changes', body: 'Needs work' }); + await assert.rejects(f.call('pm', 'request.deliver', f.args), /request-evidence-required/); +}); + +test('delivery rejects traversal, symlinks and oversized blobs', async t => { + const f = await ready(t, { invalidPaths: true }); + for (const [i, path] of ['../timer.html', 'link.html', 'large.html'].entries()) { + await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, op: `invalid-path-${i}`, path }), /artifact-path-invalid|artifact-not-regular|artifact-too-large/); + } + assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery'); +}); + +test('delivery remains readable and notification recoverable when the assistant is unavailable', async t => { + const f = await ready(t); + f.bus.broker.endLaunch({ business: 'demo', run: 'assistant-run', reason: 'exit', exitCode: 1 }); + const d = await f.call('pm', 'request.deliver', f.args); + const result = await f.call('console', 'request.artifact.console', { request: f.request, delivery: d.id }); + assert.deepEqual(Buffer.from(result.base64, 'base64'), f.bytes); + for (let i = 0; i < 2; i++) await f.bus.requestLifecycle.recover(); + const pending = f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message'); + assert.equal(pending.length, 1); + assert.equal(pending[0].result.message, d.message); +}); + +test('changed PRD records cannot authorize a new delivery', async t => { + const f = await ready(t); + await f.call('pm', 'record.write', { project: 'timer', path: 'docs/prd-1.0.md', record: '# Changed PRD\n' }); + await assert.rejects(f.call('pm', 'request.deliver', f.args), /prd-candidate-stale|requirement-snapshot-revoked/); + assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0); +}); + +test('real task.close through the tracker adapter supplies the closure used by delivery', async t => { + const f = await ready(t, { realClose: true }); + assert.ok(f.fake, 'the delivery fixture uses the real task adapter and fake tracker'); + assert.ok(f.fake.requests.some(r => r.method === 'PUT' && /^\/projects\/\d+\/views\/\d+\/buckets\/\d+\/tasks$/.test(r.path)), 'task.close wrote to the tracker'); + const d = await f.call('pm', 'request.deliver', f.args); + assert.equal(d.verdict, f.verdict.verdict); + assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete'); +}); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 1607eb7e4..4f24383cc 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -139,6 +139,10 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project } if (resolved.definition === "pm") { const control = { request: REQUEST, revision: { type: "integer", minimum: 1 }, op: str("Stable operation id; reuse only for the identical retry") }; + if (held.has("message.send")) add("deliver_request", "request.deliver", "message.send", "Deliver a reviewed HTML artifact after task_close. Cite the reviewer-authored verification record (a Markdown document containing JSON with version, task_ref, review_request, commit and results); the review verdict body must be JSON containing verification_record {path, commit} and matching results.", obj({ ...control, + task_ref: TASK_REF, verdict: str("Latest approving review verdict id"), path: str("Relative path of the committed HTML artifact"), + verification_record: obj({ path: str("Reviewer-written docs/*.md"), commit: str("Exact records commit") }, ["path", "commit"]), + }, ["request", "revision", "op", "task_ref", "verdict", "path", "verification_record"])); if (held.has("message.send")) add("advance_request", "request.advance", "message.send", "Verify evidence for the next fixed request transition. Never claim a success state; the broker derives it from admitted tasks, pushes, reviews and closure receipts.", obj(control, ["request", "revision", "op"])); if (held.has("record.write")) add("submit_prd", "prd.submit", "record.write", "Submit exact committed discovery records for PRD approval. The broker reads and binds every blob, author and requirement; submission grants no approval.", obj({ ...control, prd_version: str("Product PRD version, such as 1.0", { pattern: "^[0-9]+\\.[0-9]+(?:\\.[0-9]+)?$" }), diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index 2bbc88eac..69400aac9 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -60,7 +60,7 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory" const prompt = readFileSync(b.files.prompt, "utf8"); assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/); assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./); - assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_propose, advance_request, submit_prd, task_create, /); + assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_propose, deliver_request, advance_request, submit_prd, task_create, /); assert.doesNotMatch(prompt, /Bus tools: [^.]*\blaunch\b/); // Row 90 (#1584): PM fault 10 (#228 c91). assert.match(prompt, /^- Only the human stops a run, with `mosaic stop`\. If you think a run should stop, raise a role\.revoke decision with blocking false/m); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index fe192f6d3..6b798ec2e 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -15,7 +15,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => { const tools = typedTools({ resolved: pm, arbiter: true, tracker: true }); assert.deepEqual(names(tools), [ "send_message", "request_staff", - "intake_status", "intake_projects", "intake_open", "intake_propose", "advance_request", "submit_prd", + "intake_status", "intake_projects", "intake_open", "intake_propose", "deliver_request", "advance_request", "submit_prd", "task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change", "task_update_assigned", "task_close", "read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs index 2d19a538b..593d12b15 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -73,7 +73,7 @@ export function consoleCall({ dataRoot, socket, business = null, env = process.e const id = pinned ?? (business || hostBusiness(dataRoot)); const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_'))); return transport({ - cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args }, + cli, socket, timeoutMs, maxBytes: (verb === 'request.artifact.console' ? 2 : 1) * 1024 * 1024, input: { verb, args }, env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id, MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub }, }); diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index 32f40a057..10a01d9b6 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -24,7 +24,7 @@ const QUESTION_PHASES = ['discovery', 'prd-review', 'planning']; const MODE_PHASES = [...QUESTION_PHASES, 'implementation', 'review', 'delivery']; const PICKER_CODES = ['request-existing-project', 'request-attachment-selected']; - const ENDED = ['cancelled', 'delivered']; + const ENDED = ['cancelled', 'delivered', 'complete']; const MODES = ['autonomous', 'guided']; const ANSWER_MAX = 8000, STALE = 5000, PULSE = 10000; const positive = n => Number.isSafeInteger(n) && n > 0; @@ -153,7 +153,7 @@ if (!m.candidate) return '

No current PRD candidate or approved snapshot. Waiting for a PRD submission.

'; const c = m.candidate; let h = `

Product PRD ${txt(c.prd_version)}

-

Records commit: ${txt(c.records_commit)}
PRD: ${txt(c.prd_path)}
PRD blob: ${txt(c.prd_blob)}
Confirmed intent commit: ${txt(c.spec_commit)}
Mode revision: ${txt(c.mode_revision)}

+

Records commit: ${txt(c.records_commit)}
PRD: ${txt(c.prd_path)}
PRD blob: ${txt(c.prd_blob)}
Confirmed intent commit: ${txt(c.spec_commit)}
Mode revision: ${txt(c.mode_revision)}

${txt(m.prd)}
${m.requirements.map((r, i) => `

${txt(r.id)} — ${txt(r.title)}

${txt(c.requirements[i].path)}
Blob: ${txt(c.requirements[i].blob)}

${txt(r.text)}
`).join('')}
`; if (m.snapshot.id) { @@ -176,6 +176,16 @@ let body = `

Request Phase: ${txt(s.phase)}${s.mode ? ` Mode: ${txt(s.mode)}` : ''}

`; if (s.project) body += `

Project: ${txt(s.project)}${s.phase === 'cancelled' ? ' (kept)' : ''}

`; body += waitHtml(s); + if (s.phase === 'complete' && !s.delivery) body += '

Completed before delivery evidence.

'; + const d = s.delivery_evidence; + if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) { + body += `

Reviewed delivery — not landed or deployed.

+

Download reviewed HTML

+

Approved snapshot: ${txt(d.snapshot)}
Task: ${txt(d.task_ref)}
+ Code commit: ${txt(d.commit)}
Review request: ${txt(d.review_request)}
Verdict: ${txt(d.verdict)}
+ Verification record: ${txt(d.verification.record_path)} at ${txt(d.verification.record_commit)}
+ Blob: ${txt(d.artifact.blob)}
SHA-256: ${txt(d.artifact.sha256)}

`; + } body += prdHtml(p, busy, can); if (q) { body += q.answer === null diff --git a/packages/webui/src/request-http.mjs b/packages/webui/src/request-http.mjs index e613dd7d8..f2aad2dcc 100644 --- a/packages/webui/src/request-http.mjs +++ b/packages/webui/src/request-http.mjs @@ -17,9 +17,10 @@ // reply must name this request; a revoke receipt must name this snapshot and its next revision. A text answer is an answer to the // PM's question, never a PRD approval; the decision answer routes are decision-http.mjs's. import { BusReadError } from './bus.mjs'; +import { createHash } from 'node:crypto'; import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; -export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke))?$/; +export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+))?$/; const WRITE_VERBS = Object.freeze({ answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console', }); @@ -144,8 +145,8 @@ const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object' // transports; either is null when the Console has no such transport. `verbs` is the console verb // list: the draft read exists only while it names `request.draft.console`. export async function requestHTTP(req, res, { path, requestRead, requestWrite, session, live, origin, log, verbs = CONSOLE_VERBS }) { - const [, raw, suffix] = REQUEST_ROUTE.exec(path); - const action = suffix ?? 'get'; + const [, raw, suffix, artifactSuffix, rawDelivery] = REQUEST_ROUTE.exec(path); + const action = suffix ?? artifactSuffix ?? 'get'; const write = Object.hasOwn(WRITE_VERBS, action); let id = null; try { id = decodeURIComponent(raw); } catch { /* refused below */ } @@ -161,10 +162,34 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s const user = JSON.stringify(String(session.name ?? '').slice(0, 64)); const line = tail => log(`${new Date().toISOString()} request user=${user} request=${JSON.stringify(String(id ?? '').slice(0, 64))} action=${action}${tail}`); const refuse = (code, extra = '') => { line(`${extra} error=${code}`); return requestRefuse(res, code); }; - if (id === null || !ID.test(id)) return refuse('request-not-found'); + if (id === null || !ID.test(id) || (action !== 'artifact' && rawDelivery !== undefined)) return refuse('request-not-found'); + let delivery = null; + if (action === 'artifact') { + try { delivery = decodeURIComponent(rawDelivery ?? ''); } catch {} + if (!delivery || !ID.test(delivery)) return refuse('request-not-found'); + } const subject = { provider: session.provider, iss: session.iss, sub: session.sub }; const busCode = (err, open) => (err instanceof BusReadError ? BUS_CODES[err.code] : undefined) ?? open; if (!write) { + if (action === 'artifact') { + if (!verbs.includes('request.artifact.console')) return refuse('unavailable'); + if (live() !== session) return refuse('sign-in-required'); + let current, artifact; + try { + current = await requestRead('request.get.console', { request: id }, { subject }); + if (!state(current, id) || current.delivery !== delivery) return refuse('request-not-found'); + if (live() !== session) return refuse('sign-in-required'); + artifact = await requestRead('request.artifact.console', { request: id, delivery }, { subject }); + } catch (err) { return refuse(busCode(err, 'unavailable')); } + if (live() !== session) return refuse('sign-in-required'); + if (!artifact || artifact.mime !== 'text/html' || typeof artifact.filename !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(artifact.filename) || + typeof artifact.base64 !== 'string' || artifact.base64.length > 1398104 || !/^[A-Za-z0-9+/]*={0,2}$/.test(artifact.base64) || !/^[0-9a-f]{64}$/.test(artifact.sha256 ?? '')) return refuse('unavailable'); + const bytes = Buffer.from(artifact.base64, 'base64'); + if (bytes.length > 1024 * 1024 || bytes.toString('base64') !== artifact.base64 || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) return refuse('unavailable'); + line(''); + res.writeHead(200, { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }); + return res.end(bytes); + } const verb = READ_VERBS[action]; if (action === 'draft' && !verbs.includes(verb)) return refuse('draft-unavailable'); if (live() !== session) return refuse('sign-in-required'); diff --git a/packages/webui/tests/artifact-http.test.mjs b/packages/webui/tests/artifact-http.test.mjs new file mode 100644 index 000000000..64effb28b --- /dev/null +++ b/packages/webui/tests/artifact-http.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { REQUEST_ROUTE, requestHTTP } from '../src/request-http.mjs'; + +test('owner downloads the exact authorized bytes as a private inert attachment', async () => { + const path = '/api/requests/r1/artifact/d1'; + assert.match(path, REQUEST_ROUTE); + const bytes = Buffer.from('Timer'); + const hash = createHash('sha256').update(bytes).digest('hex'); + const session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' }; + const req = { method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } }; + const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } }; + const calls = []; + await requestHTTP(req, res, { path, session, live: () => session, origin: 'https://console.test', log() {}, requestWrite: null, + requestRead: async (verb, args) => { calls.push({ verb, args }); return verb === 'request.get.console' + ? { version: 1, request: 'r1', revision: 1, phase: 'complete', delivery: 'd1' } + : { mime: 'text/html', filename: 'timer.html', base64: bytes.toString('base64'), sha256: hash }; } }); + assert.equal(res.status, 200); + assert.deepEqual(res.data, bytes); + assert.match(res.headers['content-disposition'], /^attachment/); + assert.equal(res.headers['x-content-type-options'], 'nosniff'); + assert.match(res.headers['cache-control'], /private/); + assert.match(res.headers['cache-control'], /no-store/); + assert.deepEqual(calls.map(c => c.verb), ['request.get.console', 'request.artifact.console']); +}); + +test('artifact access refuses foreign ownership, tampered bytes and expired sessions', async () => { + const path = '/api/requests/r1/artifact/d1', session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' }; + const req = { method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } }; + const attempt = async (read, live = () => session) => { + const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } }; + await requestHTTP(req, res, { path, session, live, origin: 'https://console.test', log() {}, requestWrite: null, requestRead: read }); return res; + }; + const state = { version: 1, request: 'r1', revision: 1, phase: 'complete', delivery: 'd1' }; + assert.equal((await attempt(async () => ({ ...state, delivery: 'someone-else' }))).status, 404); + assert.equal((await attempt(async verb => verb === 'request.get.console' ? state : { mime: 'text/html', filename: 'timer.html', base64: 'eA==', sha256: '0'.repeat(64) })).status, 503); + assert.equal((await attempt(async () => state, () => null)).status, 401); +}); diff --git a/packages/webui/tests/artifact-transport.test.mjs b/packages/webui/tests/artifact-transport.test.mjs new file mode 100644 index 000000000..205c5a892 --- /dev/null +++ b/packages/webui/tests/artifact-transport.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { consoleCall } from '../src/bus.mjs'; +import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; + +test('only the artifact Console verb permits a full 2 MiB response, and oversized output is refused', async t => { + const root = mkdtempSync(join(tmpdir(), 'artifact-transport-')); t.after(() => rmSync(root, { recursive: true, force: true })); + const cli = join(root, 'console.mjs'); + writeFileSync(cli, `import {readFileSync} from 'node:fs';const {args}=JSON.parse(readFileSync(0,'utf8'));process.stdout.write(JSON.stringify('x'.repeat(args.bytes)));`); + const call = consoleCall({ dataRoot: root, socket: 'fake', business: 'demo', cli, timeoutMs: 15000 }); + const subject = { provider: 'test', iss: 'test', sub: 'owner' }; + const artifact = 'request.artifact.console', max = 2 * 1024 * 1024; + assert.ok(CONSOLE_VERBS.includes(artifact)); + assert.equal((await call(artifact, { bytes: max - 2 }, { subject })).length, max - 2); + await assert.rejects(call(artifact, { bytes: max - 1 }, { subject }), { code: 'response-too-large' }); + for (const verb of CONSOLE_VERBS.filter(v => v !== artifact)) { + assert.equal((await call(verb, { bytes: 1024 * 1024 - 2 }, { subject })).length, 1024 * 1024 - 2, verb); + await assert.rejects(call(verb, { bytes: 1024 * 1024 - 1 }, { subject }), { code: 'response-too-large' }, verb); + } +}); diff --git a/packages/webui/tests/request-browser.test.mjs b/packages/webui/tests/request-browser.test.mjs index f25aa53c4..556e55901 100644 --- a/packages/webui/tests/request-browser.test.mjs +++ b/packages/webui/tests/request-browser.test.mjs @@ -556,3 +556,25 @@ test('PRD panel: a successful mode change immediately clears the invalidated dra assert.equal(await e.count(`${P} .request-prd-text, ${P} .request-snapshot, ${P} [data-request-revoke]`), 0); assert.equal((await e.writes()).length, 1); }); + +test('legacy complete requests without delivery evidence are labeled without a verified claim or error', async t => { + const old = state({ ...APPROVED, phase: 'complete', delivery: null }); + const { w, text } = await setup(t, { boot: { states: { [R]: old }, drafts: { [R]: ACTIVE_DRAFT } } }); + await w(`document.querySelector('${P}') && document.querySelector('${P}').textContent.toLowerCase().includes('completed before delivery evidence')`); + const content = await text(P); + assert.match(content, /completed before delivery evidence/i); + assert.doesNotMatch(content, /verified delivery|delivery error/i); +}); + +test('reviewed delivery shows a download and bound evidence separately from deployment', async t => { + const evidence = { version: 1, id: 'delivery-1', request: R, business: 'demo', project: 'factory', snapshot: 'prd-approved', task_ref: 'vikunja:2/41', + commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { path: 'timer.html', blob: 'b'.repeat(40), sha256: 'c'.repeat(64), bytes: 100, mime: 'text/html' }, + verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) }, message: 'notice-1' }; + const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence }); + const { w, text, b } = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } }); + await w(`document.querySelector('${P} .request-delivery')`); + const panel = await text(`${P} .request-delivery`); + assert.match(panel, /Reviewed delivery/); assert.match(panel, /not landed or deployed/); + for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value)); + assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`); +});