Compare commits

...
23 changed files with 567 additions and 37 deletions

No files matched your search

+8
View File
@@ -6115,3 +6115,11 @@ 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.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, before | On 1904c9775, review found delivery could combine the planned reviewer closure with a newer unclosed second reviewer verdict. Added two real task.close regressions: unclosed V2 must refuse without completion or chief intent, and closed planned V1 must remain deliverable after another reviewer’s V2. Both reproduced the defect before the fix; red log /mnt/storage/scratch/tmp/cLYQkO/red.log. Queue next refused linked-worktree use; canonical checkout untouched.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, after | Bound delivery to the exact push, planned review, approving verdict and task.closed receipt accepted by request progress. A newer unclosed second-reviewer V2 now refuses without completion or chief intent; the closed planned-reviewer V1 still delivers. Both real-adapter regressions red before and green after (logs /mnt/storage/scratch/tmp/cLYQkO/red.log and green.log). Required gates zero skips: bus 1572/1572, harness 228/228, seat 239/239, webui 559/559; logs /mnt/storage/scratch/tmp/cLYQkO/. The first whole webui attempt had one timeout in decide-browser I1 (558/559); that file passed 9/9 alone on both this branch and 647626bdd, and the whole rerun passed. No proof pin, Console whitelist, transport, policy or schema change; no push.
+4
View File
@@ -2013,3 +2013,7 @@ 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.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 Task 7b B1 fix round | Delivery now uses the exact review/verdict/closure accepted by progress; real-close V2 refusal and V1 delivery regressions red then green. Bus 1572/1572, harness 228/228, seat 239/239, webui rerun 559/559, zero skips; initial webui timeout and isolated branch/base passes recorded in BUILD-LOG. Logs /mnt/storage/scratch/tmp/cLYQkO/; local commit only, no push.
+30 -4
View File
@@ -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,31 @@ 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) }));
// Use the same chain that #requestProgress accepted, including the specific close receipt.
// A later review by another role cannot replace the verdict that actually closed this task.
const accepted = new Set(progress.evidence);
const push = rows.findLast(e => accepted.has(e.id) && e.kind === 'action.allowed' && e.body.action === 'git.push.working');
const review = rows.findLast(e => accepted.has(e.id) && e.kind === 'review.requested');
const verdict = rows.findLast(e => accepted.has(e.id) && e.kind === 'review.verdict');
const close = rows.findLast(e => accepted.has(e.id) && e.kind === 'task.closed');
if (!push || !review || !verdict || !close || verdict.id !== args.verdict || close.body.verdict !== verdict.id ||
verdict.body.request !== review.id || verdict.body.commit !== push.body.commit || 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<? AND json_extract(body,'$.action')='record.write' ORDER BY seq", state.business, verdict.actor_role, verdict.seq)
.find(e => { 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 +805,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');
+3 -3
View File
@@ -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);
}
+66 -3
View File
@@ -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');
+2
View File
@@ -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;
};
+2 -2
View File
@@ -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;
});
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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);
+3 -3
View File
@@ -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);
+91
View File
@@ -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(`<!doctype html><title>Timer</title><output id="clock">Ready</output><button id="start">Start</button><script>document.querySelector('#start').onclick=()=>{document.querySelector('#clock').textContent='Running';};</script>`);
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 };
}
+5 -4
View File
@@ -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');
@@ -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);
});
@@ -0,0 +1,182 @@
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');
});
async function newerUnclosedReview(f) {
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const config = bootConfig({ system: loadSystem({ env: f.f.env }), businessId: 'demo', env: f.f.env, warn: () => {} });
const business = config.businesses.demo;
business.roles.reviewer2 = structuredClone(business.roles.reviewer);
business.roles.reviewer2.tracker = { bot: 'bot-demo-reviewer2', botId: f.fake.user('reviewer2') };
business.launch.instances.push('reviewer2');
f.bus.broker.reloadBusiness('demo', business);
const cap = f.bus.broker.bindLaunch({ business: 'demo', role: 'reviewer2', run: 'reviewer2-run', harness: 'pi' });
f.bus.broker.request(cap, { verb: 'role.claim' });
const review = f.call('coder', 'review.request', { task_ref: f.task_ref, commit: f.commit, to: 'reviewer2' });
const results = 'Reviewer two independently passed acceptance.';
const verification = await f.bus.broker.request(cap, { verb: 'record.write', args: { project: 'timer', path: 'docs/verification2.md',
record: JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: review.request, commit: f.commit, results }) } });
const verdict = f.bus.broker.request(cap, { verb: 'review.verdict', args: { task_ref: f.task_ref, request: review.request, verdict: 'approve',
body: JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results }) } });
return { review, verdict, verification };
}
test('refuses a newer approval whose verdict was never cited by the task closure', async t => {
const f = await ready(t, { realClose: true }), v2 = await newerUnclosedReview(f);
const closures = f.store.all("SELECT body FROM events WHERE business=? AND kind='task.closed' AND subject=?", 'demo', f.task_ref);
assert.equal(closures.length, 1);
assert.equal(JSON.parse(closures[0].body).verdict, f.verdict.verdict);
await assert.rejects(f.call('pm', 'request.deliver', { ...f.args, verdict: v2.verdict.verdict,
verification_record: { path: v2.verification.path, commit: v2.verification.commit } }), { code: 'request-evidence-required' });
assert.equal(f.journal.readRequest('demo', f.request).phase, 'delivery');
assert.equal(f.store.get("SELECT count(*) n FROM request_events WHERE business=? AND request=? AND kind='advanced' AND json_extract(body,'$.phase')='complete'", 'demo', f.request).n, 0);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 0);
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'delivery' && e.stage === 'committed').length, 0);
});
test('delivers the closed planned review even after another reviewer gives a newer unclosed approval', async t => {
const f = await ready(t, { realClose: true }), v2 = await newerUnclosedReview(f);
const d = await f.call('pm', 'request.deliver', f.args);
assert.equal(d.review_request, f.review.request);
assert.equal(d.verdict, f.verdict.verdict);
assert.notEqual(d.verdict, v2.verdict.verdict);
assert.equal(d.verification.record_commit, f.verification.commit);
assert.equal(f.journal.readRequest('demo', f.request).phase, 'complete');
assert.equal(f.journal.effects('demo', f.request).filter(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.step === 'send_message').length, 1);
});
+4
View File
@@ -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]+)?$" }),
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 },
});
+12 -2
View File
@@ -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 '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
const c = m.candidate;
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <code>${txt(c.prd_path)}</code><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
if (m.snapshot.id) {
@@ -176,6 +176,16 @@
let body = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
body += waitHtml(s);
if (s.phase === 'complete' && !s.delivery) body += '<p class="request-legacy-complete">Completed before delivery evidence.</p>';
const d = s.delivery_evidence;
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) {
body += `<section class="request-delivery" aria-label="Reviewed delivery"><p><b>Reviewed delivery</b> — not landed or deployed.</p>
<p><a href="/api/requests/${enc(s.request)}/artifact/${enc(d.id)}" download>Download reviewed HTML</a></p>
<p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
Code commit: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.commit)}</code></a><br>Review request: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.review_request)}</code></a><br>Verdict: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.verdict)}</code></a><br>
Verification record: <a href="#/docs/${enc(d.project)}/${enc(d.verification.record_path)}">${txt(d.verification.record_path)}</a> at <code>${txt(d.verification.record_commit)}</code><br>
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></section>`;
}
body += prdHtml(p, busy, can);
if (q) {
body += q.answer === null
+29 -4
View File
@@ -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');
@@ -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('<!doctype html><title>Timer</title>');
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);
});
@@ -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);
}
});
@@ -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`);
});