Compare commits

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

No files matched your search

+3
View File
@@ -6118,3 +6118,6 @@ Local commit only; no push or live instance/credential-file access.
2026-11-04 | Codex implementation sub-agent | #803 Console Model proxy in Settings | Before: the owner's native Model proxy page was linked above the Console tree at /proxy. After: Settings links to /settings/model-proxy, whose page links back to Settings; /proxy redirects there only after the existing sign-in, granted-user and named-host fences. The proxy script, fixed API routes, human bus verbs, read and write authority, origin, Fetch Metadata, transport and bounded body checks are unchanged. No secret values are displayed. Tests first: expected red 3 failures (new Settings link, redirect and tester fence), 18 passes, zero skips; final targeted proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero failures, cancellations or skips. Each test command used a fresh TMPDIR under /mnt/storage/scratch/tmp/XXXXXX; logs /mnt/storage/scratch/tmp/console803-logs/{red,proxy-page,proxy-browser,views,webui}.log. Root dependencies were symlinked and webui dependencies linked per entry without installation. Queue next in this worktree refused the linked checkout; canonical queue next t3 returned nothing, with no queue movement. Local commit only; no push, live configuration or secrets access.
2026-11-04T18:34:22Z | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question before/after | Before: base 647626bdd exposes request.question on the bus but no PM harness tool. After: PM with message.send gets intake_question in both harnesses, forwarding only request, revision, op, text and optional bounded choices; the existing broker enforces exact shape, distinct choice keys, authority and replay. A stable caller-supplied op and exact retry use the broker journal, while bus refusals remain errors and lost/unknown outcomes are reported as unknown with no automatic retry. PM duty prose describes status reconciliation and that answers do not approve a PRD. Red evidence: request-tools red-real.log 19/25, with missing tool and unknown-outcome failures; earlier fixture corrections are retained in the logs. Final gates: harness 234/234, bus 1556/1556, business 307/307, all zero failures/skips/cancellations; tests include both harness manifests, unauthorized roles, invalid input, refusal propagation, unknown outcome, stable-op replay and owner Console answer through the real bus. Logs: /mnt/storage/scratch/tmp/W6Jjos/; each invocation used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and pipefail. No bus, role JSON, contract or migration change; local commit only, no push. Canonical queue next t3 returned nothing; coordination stays with the controller.
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.
+4
View File
@@ -2015,3 +2015,7 @@ are never rewritten or removed; corrections are new entries.
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 | Codex implementation sub-agent, t3/console-803-proxy-settings | #803 Model proxy in Settings | Protected /settings/model-proxy and checked /proxy redirect; unchanged proxy API and authority. Red first; proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero skips; logs /mnt/storage/scratch/tmp/console803-logs/. Linked-worktree queue refusal; canonical queue next t3 returned nothing. Local commit only, no push or live/secret access.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question tool | PM-only typed request.question, strict bounded broker arguments, stable-op replay, refusal and unknown-outcome propagation, owner-answer integration and PM duty prose; red-first; harness 234/234, bus 1556/1556, business 307/307, zero skips; logs /mnt/storage/scratch/tmp/W6Jjos/. Local commit only; no push, bus, role JSON, contract or migration changes. Canonical queue next t3 returned nothing.
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 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | #797 / Gitea #1839 lifecycle Task 7b rebase onto 34e9af616 | Cherry-picked 1904c9775 as one local commit; unioned BUILD-LOG and SESSIONS stages 1/2/3 and retained both intake_question and deliver_request in harness tool/bundle expectations. Post-pick checks: webui bus.mjs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d, channel-proof pin 34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692. Bus 1570/1570, harness 234/234, business 307/307, seat 239/239, zero skips. WebUI exact full runs: 562/563 with decide-browser timeout, isolated decide-browser 9/9; second run 562 passed/1 cancelled on tester-cli timeout, isolated tester-cli 2/2; final concurrency-4 run 563/563, zero skips. Logs: /mnt/storage/scratch/tmp/FcQIVV/bus.log, /mnt/storage/scratch/tmp/b3p9Ff/harness.log, /mnt/storage/scratch/tmp/YSkAka/business.log, /mnt/storage/scratch/tmp/4Qkhl5/seat.log, /mnt/storage/scratch/tmp/Zcgo8v/webui.log, /mnt/storage/scratch/tmp/n0o7aV/decide-browser.log, /mnt/storage/scratch/tmp/HIdzOF/webui.log, /mnt/storage/scratch/tmp/gBCdZz/tester-cli.log, /mnt/storage/scratch/tmp/GyMQoN/webui.log. No push.
+24 -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,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<? 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 +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');
+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,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');
});
+4
View File
@@ -149,6 +149,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_question, intake_propose, advance_request, submit_prd, task_create, /);
assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_question, 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_question", "intake_propose", "advance_request", "submit_prd",
"intake_status", "intake_projects", "intake_open", "intake_question", "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`);
});