diff --git a/BUILD-LOG.md b/BUILD-LOG.md index dc5f7017b..8fd6ecf4c 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6058,3 +6058,58 @@ SSH fixture; only its owned test child and scratch daemon were terminated, the interrupted evidence was kept at `/mnt/storage/scratch/tmp/task5b-bus-interrupted.log`, and the isolated full rerun passed. All started runs were joined before completion. Local commit only, no push, no live instance or credential-file access. + +## 2026-11-04 — #797 / #1839 Task 4 Console slice — before + +On `t3/lifecycle-task-4-ui` from b5a58b48b. The lead authorized the missing +owner-only draft snapshot counter after the initial UI investigation stopped: +add `{id, revision}` from the project's snapshot events, without a migration or +new verb. Build the exact PRD/requirements panel, existing decision confirmation +and guarded owner revoke route. Keep bus edits in draftConsole; leave broker, +runtime, roles and harness untouched. Verify targeted request/activation tests +and whole webui/bus suites in fresh scratch TMPDIRs; commit locally, never push. + +## 2026-11-04 — #797 / #1839 Task 4 Console slice — after + +The owner request panel now shows the exact current product PRD version, full +PRD and requirements, selected record paths/blob IDs, records/spec commits and +mode revision. Guided approval reuses the existing one-use decision flow and +names that candidate; changed or refused material drops its controls. The owner +can explicitly revoke an approved snapshot with a bounded reason. The HTTP route +binds project/snapshot to the URL's request and sends the snapshot-event counter, +never request revision. Refusals stay refusals, uncertain receipts stay unknown, +and neither is retried. Sign-out purges material and ignores late reads/writes. +Mode-change receipts immediately clear invalidated material. + +The lead-approved bus adjustment is confined to draftConsole in +request-lifecycle.mjs: retain current owner authorization/freshness checks and +add snapshot {id, revision} from the request's active snapshot and the project's +latest snapshotEvents revision. No candidate returns null candidate/PRD and an +empty requirement list, so the counter remains readable after revocation. No +broker, runtime, requirement-catalog, migration, role or harness file changed. + +Final gates (all zero failures, cancellations and skips): whole bus **1540/1540** +(bus.log); whole webui **554/554** (webui-verified.log, test-concurrency=4); +request-http + request-browser **42/42** (targeted-verified.log). Each run used +its own mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX. Dependencies were +linked without installation: root node_modules symlink; webui node_modules a +real directory with four per-entry symlinks to the canonical checkout. + +Evidence directory: /mnt/storage/scratch/tmp/6YKcQI/. Earlier green runs: +activation + HTTP 42/42 (green-http-bus.log), browser 19/19 (browser-first.log), +requests 41/41 (targeted-webui.log), first whole webui 553/553 (webui.log), +requests 42/42 after invalidation (targeted-webui-final.log), and isolated +send + tester-CLI 14/14 (refusal-timeout-check.log). Expected red runs: +activation 21/22 (red-bus.log), HTTP 14/20 (red-http.log), requests 26/39 +(red-webui.log), invalidation 0/1 (red-invalidation.log), uncertain receipt +0/1 (red-unknown.log). Every red run also had zero skips. + +One intermediate whole-webui run (webui-final.log) had 552 passing out of 554, +one failure and one cancellation: send.test.mjs's "without --send the route is +read-only like every other write, and config says no send" got provider-unavailable +503 at sign-in; tester-cli.test.mjs's private-Unix-socket CLI test timed out at +30 seconds. Host load was above 52 on 16 CPUs. Both files passed 14/14 isolated; +the full rerun at four concurrent files passed 554/554 without changing any wait +or timeout. The same 15-second __stubReady browser waits remain. All started +runs were joined. Queue remains with the lead after the linked-worktree refusal. +Local commit only; no push or live instance/credential-file access. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 144056116..cd385ce49 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2008,3 +2008,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers: 3p->3q migration, PM-only request.question, owner-only request.answer/attach/mode/cancel.console verbs, request-http.mjs routes with owner, same-origin and bounded-body checks, requests.js panel under the owner's chat message (mode choice through the decision confirmation, guided answers, project attach, mode change, cancel, reload-safe lost answer). Bus 1492/1492, webui 538/538, intake+bootstrap 70/70, zero skips; local commit only 2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Rebased 7fd0c3562 and c52218c65 onto c098fa022 (Task 4 core, #802). Migration renamed 3p-3q to 3q-3r, SCHEMA_VERSION 3r; Task 4's 3p-3q left byte-identical; the kind list is the union (Task 4's prd.submitted, prd.activated, prd.invalidated, mode.changed plus question.asked, question.answered, attach.selected). Task 3's own request.mode.console dropped for Task 4's modeConsole; webui maps request-mode-revision-conflict and reads Task 4's draft shape. Bus 1536/1536, webui 540/540, targeted bus 108/108 and webui 28/28, prd-activation 21/21, project prd 6/6, zero skips; not pushed. - 2026-11-04 — Codex implementation sub-agent — Lifecycle Task 5b (#797/#1839): shared scoped catalogue admission, explicit validated Stack binding, TaskScope/provenance and Git project guards; based on 103168614 plus cherry-picked Task 4 1627e24d7. Final gates 35/107/132/1501 (+32 configuration/tools), zero skips; logs /mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log. No migration, local commit only, never pushed; no live instance touched. + +2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-4-ui | #797 / Gitea #1839 lifecycle Task 4 Console slice | Exact PRD/requirements panel, existing decision confirmation and owner revoke with route/session/origin/body binding; lead-approved owner draft snapshot-event counter in draftConsole only, no migration/roles/harness/broker/runtime edits. Final bus 1540/1540, webui 554/554 (concurrency 4), targeted Console 42/42, zero skips; evidence /mnt/storage/scratch/tmp/6YKcQI/ includes expected red tests, the interim SSO/CLI timing failures and isolated 14/14 recovery. Linked-worktree queue refusal left to lead; local commit only, no push or live/credential-file access. diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index 16c379bb0..7cdaf387c 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -397,11 +397,13 @@ export function createRequestLifecycle({ journal, services }) { draftConsole: async (identity, args) => { exact(args, ['request']); if (!id(args.request)) refuse('invalid-request'); const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft'); - if (!state.candidate) refuse('prd-candidate-required'); - const material = await services.readCandidate(state.candidate, state.business); + const material = state.candidate ? await services.readCandidate(state.candidate, state.business) + : { candidate: null, prd: null, requirements: [] }; const current = stateOf(state.business, state.request); authorize(identity, current, 'draft'); if (current.revision !== state.revision) refuse('request-revision-conflict'); - return { candidate: material.candidate, prd: material.prd, requirements: material.requirements }; + const revision = current.project ? journal.snapshotEvents(current.business, current.project).at(-1)?.revision ?? 0 : 0; + return { candidate: material.candidate, prd: material.prd, requirements: material.requirements, + snapshot: { id: current.snapshot, revision } }; }, resume, catalog: services.catalog, decisionClosed: (business, decision) => { if (closing) return Promise.resolve(); diff --git a/packages/bus/tests/prd-activation.test.mjs b/packages/bus/tests/prd-activation.test.mjs index 0d7ad106d..8027a30e6 100644 --- a/packages/bus/tests/prd-activation.test.mjs +++ b/packages/bus/tests/prd-activation.test.mjs @@ -168,6 +168,29 @@ test('owner draft read shows exact bound PRD and requirements without a generic for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' }); }); +test('owner draft snapshot counter follows activation and revoke independently of the request revision', async t => { + const f = await setup(t); + const read = () => f.call('console', 'request.draft.console', { request: f.request }); + assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } }); + const sub = await f.submit(); + assert.deepEqual((await read()).snapshot, { id: null, revision: 0 }); + f.approve(sub); + const snap = await f.activate(sub), active = await read(); + assert.deepEqual(active.snapshot, { id: snap.id, revision: 1 }); + assert.notEqual(active.snapshot.revision, f.journal.readRequest('demo', f.request).revision); + const args = { project: 'timer', snapshot: active.snapshot.id, revision: active.snapshot.revision, reason: 'Replace this version', op: 'revoke-read' }; + await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, revision: f.journal.readRequest('demo', f.request).revision }), { code: 'request-revision-conflict' }); + const receipt = await f.call('console', 'prd.revoke.console', args); + assert.equal(receipt.kind, 'revoked'); + assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } }); + await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, op: 'stale-read' }), { code: 'request-revision-conflict' }); + for (const who of ['pm', 'cto', 'coder', 'human', 'reader']) + assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' }); + // The next submission sees the existing project counter even though it has no active snapshot yet. + await f.call('pm', 'prd.submit', { ...f.args, revision: f.journal.readRequest('demo', f.request).revision, op: 'prd-again' }); + assert.deepEqual((await read()).snapshot, { id: null, revision: 2 }); +}); + test('3p to 3r migration preserves existing request rows and the append-only guards', async t => { const { readFileSync, chmodSync } = await import('node:fs'); const { Store, schemaDigest } = await import('../src/store.mjs'); diff --git a/packages/webui/README.md b/packages/webui/README.md index 313e1dc1a..ba898da2c 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -370,13 +370,15 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`. ten-second beat while a thread is shown, and never writes by mounting. It needs `--sso`; its writes need `--send` too (otherwise the panel reads and says it is read-only; `/api/config` reports `requests` and `requestWrites` to the owner - only). Four owner writes, each `POST` with a single same-origin `Origin`, + only). Five owner writes, each `POST` with a single same-origin `Origin`, `Sec-Fetch-Site: same-origin`, `application/json`, a bounded body of exactly these fields and a live session, spawning `console-cli.mjs` with the signed-in subject (the page never names a business, subject or verb): `/answer {revision, question, op, text}` (64 KB body, 8000 characters of text), `/attach {revision, project, op}`, `/mode {revision, mode_revision, mode, op}`, - `/cancel {revision, op}`. Each returns the new request state; a bus refusal + `/cancel {revision, op}`, and Task 4's `/revoke {project, snapshot, revision, reason, op}`. + The first four return the new request state; revoke returns a bound snapshot + revocation receipt. A bus refusal maps to a named code and any unrecognised outcome is `outcome-unknown`, never retried. Choosing the mode (new autonomous, new guided, or an existing project) answers the selection decision through the same confirmation endpoints as the @@ -396,9 +398,32 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`. reads the request first, and only the owner's click sends the stored answer again with the same operation id, so the bus records it once. Sign-out clears the panels and the stored answers. `GET /api/requests//draft` relays - the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`) when - its candidate names that request, `prd-candidate-required` (409) before a PRD is - submitted, and `draft-unavailable` (503) on a bus whose verb list lacks the verb. + the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`, + `snapshot`). Task 4 displays the exact PRD version, full PRD and requirement + text, selected paths/blob IDs, records commit, confirmed intent commit and mode + revision. Text is inert, including control/bidi characters. A missing or + mismatched draft offers no approval or revoke action. Guided PRD decisions + use the existing confirmation/resolve endpoints; their confirmation names the + displayed version and records commit. A changed candidate or request drops + the confirmation. Autonomous decisions stay with their eligible arbiter. + The panel shows the approved snapshot and offers **Revoke approved snapshot** + to a writable owner. Revocation requires a reason (1–1024 characters) and an + explicit confirmation; it keeps existing evidence and makes pending work wait + for replacement authority. The route checks project and snapshot against the + URL's current request before writing. Its revision is the draft read's + `snapshot.revision`, the project's latest snapshot-event counter, never the + request revision. The body limit is 4096 bytes. Stale revisions refuse 409 + `request-revision-conflict`; a foreign snapshot refuses 403 + `requirement-snapshot-refused`. A refusal is reported, and a lost or malformed + receipt remains unknown; neither retries. The page reads again after a revoke. + With no candidate (including after revocation), the draft read returns + `candidate: null`, `prd: null`, `requirements: []` and `snapshot: {id: null, + revision}`; revision is zero before the project's first snapshot event. + Changed selected PRD/requirement content refuses 409 `prd-candidate-stale`, + changed intent refuses 409 `stale-spec`, and a bus whose verb list lacks the + draft read refuses 503 `draft-unavailable`. These routes remain owner-only; + without SSO, request GETs refuse 503 and POSTs refuse 403. Sign-out clears the + draft panel and ignores late replies. - What no Q1 verb returns is labelled where it would show, for example "Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed. - A task's list row can only say its current state came from a Vikunja read, diff --git a/packages/webui/src/public/chat.css b/packages/webui/src/public/chat.css index ccad756db..ec088e694 100644 --- a/packages/webui/src/public/chat.css +++ b/packages/webui/src/public/chat.css @@ -62,3 +62,6 @@ .request-panel .btn { margin-block: .15rem; } .request-panel [aria-pressed="true"] { outline: 2px solid var(--action); outline-offset: -2px; } .request-receipt:focus { outline: 2px solid var(--action); outline-offset: 2px; } +.request-prd { margin-block: .75rem; border-block: 1px solid var(--border); padding-block: .5rem; } +.request-prd pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); } +.request-requirement { margin-block: .75rem; } diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index 214ef5f17..32f40a057 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -9,7 +9,7 @@ - Nothing is sent twice by this page. A refused or lost write is reported and left for a deliberate click; a lost answer is stored (sessionStorage) before it is sent, and after a reload the page reads the request first and offers the same operation again, never sends it by itself. - - The mode decision uses the existing confirmation endpoints (/api/decisions/:id/confirmation and + - Mode and guided PRD decisions use the existing confirmation endpoints (/api/decisions/:id/confirmation and /resolve), exactly like the inbox answer panel. A text answer opens no decision. - After sign-out nothing is read, nothing is drawn and a late response changes nothing. */ (() => { @@ -30,6 +30,21 @@ const positive = n => Number.isSafeInteger(n) && n > 0; const text = v => typeof v === 'string'; const isState = (v, id) => !!v && typeof v === 'object' && !Array.isArray(v) && v.version === 1 && v.request === id && positive(v.revision) && text(v.phase); + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' + ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); + const isMaterial = (v, s) => { + if (!v || !Array.isArray(v.requirements) || !v.snapshot || !Number.isSafeInteger(v.snapshot.revision) || v.snapshot.revision < 0 || + v.snapshot.id !== s.snapshot || !(v.snapshot.id === null || text(v.snapshot.id) && ID.test(v.snapshot.id) && positive(v.snapshot.revision))) return false; + if (!s.candidate) return v.candidate === null && v.prd === null && v.requirements.length === 0; + const c = v.candidate; + return same(c, s.candidate) && c.request === s.request && c.project === s.project && c.mode_revision === s.mode_revision && + text(c.prd_version) && text(c.records_commit) && text(c.prd_path) && text(c.prd_blob) && text(c.spec_commit) && + text(v.prd) && Array.isArray(c.requirements) && c.requirements.length > 0 && c.requirements.length <= 64 && + c.requirements.length === v.requirements.length && v.requirements.every((r, i) => r && text(r.id) && text(r.title) && text(r.text) && + c.requirements[i] && r.id === c.requirements[i].id && r.title === c.requirements[i].title && text(c.requirements[i].path) && text(c.requirements[i].blob)); + }; + const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject); const panels = new Map(); let signedOut = false, timer = null, config = null, loading = null; @@ -82,8 +97,8 @@ let p = panels.get(id); if (!p) { const stored = load(id); - p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, writes: false, at: 0, reading: null, stored, sig: '', - ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, draft: stored?.text ?? '' } }; + p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, reading: null, stored, sig: '', + ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } }; panels.set(id, p); } return p; @@ -106,6 +121,11 @@ if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.'; } function adopt(p, s) { + if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null; + if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false; + if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) { + p.material = null; p.materialError = ''; + } if (!p.state || s.revision >= p.state.revision) p.state = s; p.missing = false; p.failed = false; p.stale = false; reconcile(p); @@ -126,14 +146,37 @@ return `

${line}

`; } + function prdHtml(p, busy, can) { + if (p.materialError) return `

${txt(p.materialError)} Approval and revocation are unavailable. Use Read again.

`; + const m = p.material; + if (!m) return ''; + if (!m.candidate) return '

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

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

Product PRD ${txt(c.prd_version)}

+

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

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

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

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

${txt(r.text)}
`).join('')}
`; + if (m.snapshot.id) { + h += `

Approved snapshot: ${txt(m.snapshot.id)}

`; + if (can) h += `
${p.ui.revokeAsk + ? `

Revoke this approved snapshot? Pending work will wait for replacement authority. Existing evidence is kept.

+ + +
` + : ``}
`; + } else h += '

This candidate has no active approved snapshot.

'; + return h + '
'; + } + function html(p) { const s = p.state, ui = p.ui; if (!s) return p.failed ? `

This request could not be read.

` : ''; - const busy = ui.pending ? ' disabled' : '', can = p.writes; + const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale; const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null; let body = `

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

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

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

`; body += waitHtml(s); + body += prdHtml(p, busy, can); if (q) { body += q.answer === null ? `

The project manager asks:

${txt(q.text)}

` @@ -143,11 +186,12 @@ if (can && ui.confirm) { const c = ui.confirm; body += `

${txt(c.question)}

Your choice: ${txt(c.text)}

+ ${c.candidate ? `

Exact product PRD ${txt(c.candidate.prd_version)} at ${txt(c.candidate.records_commit)}. The PRD and requirements are shown above.

` : ''}

Confirm to record this choice. This confirmation works once and lasts two minutes.

`; - } else if (can && s.phase === 'selection' && p.inbox) { - body += `

Each button opens a confirmation first. Nothing is answered until you confirm.

${p.inbox.console.options.map(o => + } else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) { + body += `

Each button opens a confirmation first. Nothing is answered until you confirm.

${p.inbox.console.options.map(o => ``).join(' ')}
`; } if (can && pickerOpen(s)) { @@ -185,32 +229,45 @@ // Redraw every mount of this request, keeping the keyboard where it was. function draw(p) { if (out() || panels.get(p.id) !== p) return; + p.sig = signature(p); for (const el of document.querySelectorAll('.request-mount[data-request]')) { if (el.dataset.request !== p.id) continue; const a = document.activeElement, inside = !!a && a !== document.body && el.contains(a); - const area = inside && a.matches('textarea') ? { start: a.selectionStart, end: a.selectionEnd } : null; + const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null; const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null; el.innerHTML = html(p); if (!inside) continue; let focus = null; if (area) { - focus = el.querySelector('textarea'); + focus = el.querySelector(`#${CSS.escape(area.id)}`); if (focus) focus.setSelectionRange(area.start, area.end); } else if (mark) focus = el.querySelector(`[${mark.name}="${CSS.escape(mark.value)}"]`); (focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true }); } } - const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.failed, p.stale, p.missing, p.writes]); + const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, Boolean(p.reading)]); // The reads that follow a state: the mode decision the selection waits on and the project picker. async function side(p) { const s = p.state; - p.inbox = null; + p.inbox = null; p.material = null; p.materialError = ''; + if (s.project) { + const { res, json } = await call(`/api/requests/${enc(p.id)}/draft`); + if (out() || panels.get(p.id) !== p) return; + if (res?.ok && isMaterial(json, s)) p.material = json; + else { + p.ui.confirm = null; p.ui.revokeAsk = false; + p.materialError = res && !res.ok && text(json?.error) + ? `Draft refused: ${text(json.message) ? json.message : json.error} (${json.error}).` + : 'The exact draft could not be verified against this request.'; + } + } if (!p.writes) { p.projects = null; return; } - if (s.phase === 'selection' && text(s.selection?.decision)) { + const decision = s.phase === 'selection' ? s.selection?.decision : prdDecision(s) && p.material?.candidate ? s.wait.subject : null; + if (text(decision)) { const { res, json } = await call('/api/bus/inbox'); if (out() || panels.get(p.id) !== p) return; - const row = res?.ok && Array.isArray(json?.rows) ? json.rows.find(r => r && r.id === s.selection.decision) : null; + const row = res?.ok && Array.isArray(json?.rows) ? json.rows.find(r => r && r.id === decision) : null; if (row && row.console?.kind === 'decision' && options(row.console.options).length === row.console.options.length && row.console.options.length) p.inbox = row; } if (pickerOpen(s)) { @@ -229,12 +286,13 @@ if (out() || panels.get(p.id) !== p) return; if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); } else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; } - else if (p.state) p.stale = true; + else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; } else p.failed = true; p.at = Date.now(); if (out() || panels.get(p.id) !== p) return; - const sig = signature(p); - if (sig !== p.sig) { p.sig = sig; draw(p); } + // The read is complete before drawing, so a successful refresh re-enables controls. + p.reading = null; + if (signature(p) !== p.sig) draw(p); } finally { p.reading = null; } })(); return p.reading; @@ -246,17 +304,20 @@ const { res, json } = await post(`/api/requests/${enc(p.id)}/${action}`, body); if (out() || panels.get(p.id) !== p) return null; p.ui.pending = null; - if (res?.ok && isState(json, p.id)) return { ok: true, state: json }; - if (res && res.status >= 400 && res.status < 500 && res.status !== 408 && text(json?.error)) return { refused: true, code: json.error, message: text(json.message) ? json.message : json.error }; + if (res?.ok && (action === 'revoke' + ? json?.kind === 'revoked' && json.snapshot === body.snapshot && json.revision === body.revision + 1 && text(json.cause) + : isState(json, p.id))) return { ok: true, state: action === 'revoke' ? null : json }; + if (res && !res.ok && res.status !== 408 && text(json?.error) && json.error !== 'outcome-unknown') + return { refused: true, code: json.error, message: text(json.message) ? json.message : json.error }; return { unknown: true }; } // The receipt and the follow-up read for one write's result. A refused or lost write is read // against, never repeated. function settle(p, r, okText, noText) { if (!r) return false; - if (r.ok) { adopt(p, r.state); p.ui.receipt = okText; } + if (r.ok) { if (r.state) adopt(p, r.state); p.ui.receipt = okText; } else if (r.refused) p.ui.receipt = `${noText}: ${r.message} (${r.code}).`; - else p.ui.receipt = 'Outcome unknown: the change may have reached the project manager. Nothing was sent again; the request is being read.'; + else p.ui.receipt = 'Outcome unknown: the change may have reached the bus. Nothing was sent again; the request is being read.'; p.sig = signature(p); draw(p); if (!r.ok) void read(p); return r.ok === true; @@ -305,7 +366,19 @@ settle(p, await send(p, 'cancel', { revision: s.revision, op: opId('cancel') }), 'Request cancelled. Any project already created is kept.', 'Not cancelled'); } - // The mode decision. The first click only asks the Console for a one-use confirmation that names + async function revoke(p) { + const s = p.state, m = p.material, ui = p.ui; + if (!p.writes || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return; + if (!ui.reason.trim() || ui.reason.length > 1024 || ui.reason.includes('\0')) { ui.receipt = 'Write a reason of 1–1024 characters before revoking.'; draw(p); return; } + ui.receipt = ''; + const r = await send(p, 'revoke', { project: s.project, snapshot: m.snapshot.id, revision: m.snapshot.revision, reason: ui.reason, op: opId('revoke') }); + if (!r) return; + if (r.ok) { ui.revokeAsk = false; ui.reason = ''; p.material = null; } + settle(p, r, 'Approved snapshot revoked. Pending work waits for replacement authority; existing evidence is kept.', 'Not revoked'); + if (r.ok) void read(p); + } + + // The mode or guided PRD decision. The first click only asks the Console for a one-use confirmation that names // the answer; the second records it. A refusal drops the confirmation and an unknown outcome is // reported, never repeated. const DECIDE_FALLBACK = { @@ -317,13 +390,16 @@ const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`; async function choose(p, key) { const row = p.inbox, ui = p.ui; - if (!p.writes || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; + if (!p.writes || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; + const revision = p.state.revision, candidate = prdDecision(p.state) ? p.material?.candidate : null; + if (prdDecision(p.state) && !candidate) return; ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p); const { res, json } = await post(`/api/decisions/${enc(row.id)}/confirmation`, { choice: key }); if (out() || panels.get(p.id) !== p) return; ui.pending = null; - if (res?.ok && json?.id === row.id && json.action === 'answer' && json.kind === 'decision' && !json.grant && json.choice === key && text(json.text) && text(json.confirmation)) { - ui.confirm = { id: row.id, choice: key, text: json.text, question: text(json.question) ? json.question : '', confirmation: json.confirmation }; + if (p.state.revision === revision && same(candidate, prdDecision(p.state) ? p.material?.candidate : null) && + res?.ok && json?.id === row.id && json.action === 'answer' && json.kind === 'decision' && !json.grant && json.choice === key && text(json.text) && text(json.confirmation)) { + ui.confirm = { id: row.id, choice: key, text: json.text, question: text(json.question) ? json.question : '', confirmation: json.confirmation, candidate, revision }; draw(p); } else { ui.receipt = notAnswered(refusal(res, json, 'confirmation-unavailable')); draw(p); @@ -332,7 +408,8 @@ } async function confirm(p) { const c = p.ui.confirm, ui = p.ui; - if (!p.writes || ui.pending || !c) return; + if (!p.writes || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision || + c.candidate && (!same(c.candidate, p.material?.candidate) || p.state.wait?.subject !== c.id)) return; ui.pending = 'confirm'; ui.receipt = ''; draw(p); const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); if (out() || panels.get(p.id) !== p) return; @@ -349,7 +426,7 @@ const el = button?.closest('.request-panel'); if (!button || !el || out()) return; const p = panels.get(el.dataset.request); - if (!p || button.disabled) return; + if (!p || button.disabled || p.ui.pending || p.reading) return; const d = button.dataset; if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); } else if ('requestChoice' in d) void choose(p, d.requestChoice); @@ -361,21 +438,26 @@ if (o) { p.ui.draft = o.text; draw(p); el.querySelector('textarea')?.focus({ preventScroll: true }); } } else if ('requestResend' in d) void answer(p, true); else if ('requestMode' in d) void mode(p, d.requestMode); + else if ('requestRevoke' in d) { p.ui.revokeAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('.request-revoke textarea')?.focus({ preventScroll: true }); } + else if ('requestRevokeBack' in d) { p.ui.revokeAsk = false; draw(p); } else if ('requestCancel' in d) { p.ui.cancelAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('[data-request-cancel-keep]')?.focus({ preventScroll: true }); } else if ('requestCancelKeep' in d) { p.ui.cancelAsk = false; draw(p); } else if ('requestCancelConfirm' in d) void cancel(p); }); document.addEventListener('submit', event => { - const form = event.target.closest?.('form.request-answer'); + const form = event.target.closest?.('form.request-answer, form.request-revoke-form'); if (!form) return; event.preventDefault(); const p = panels.get(form.closest('.request-panel')?.dataset.request); - if (p && !out()) void answer(p); + if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); } }); document.addEventListener('input', event => { const area = event.target.closest?.('.request-answer textarea'); const p = area && panels.get(area.closest('.request-panel')?.dataset.request); if (p && !out()) p.ui.draft = area.value; + const reason = event.target.closest?.('.request-revoke textarea'); + const owner = reason && panels.get(reason.closest('.request-panel')?.dataset.request); + if (owner && !out()) owner.ui.reason = reason.value; }); // The Console does not push request changes, so a shown panel re-reads on a slow beat while the page diff --git a/packages/webui/src/request-http.mjs b/packages/webui/src/request-http.mjs index 5cb43ca18..e613dd7d8 100644 --- a/packages/webui/src/request-http.mjs +++ b/packages/webui/src/request-http.mjs @@ -1,24 +1,27 @@ -// #797 / #1839 Task 3: the Console's request intake routes. Two reads and four owner writes over one +// #797 / #1839 Tasks 3/4: the Console's request intake routes. Reads and owner writes over one // request, each through the console wrapper (consoleCall) as the signed-in (provider, iss, sub): // GET /api/requests/:request request.get.console -> RequestState // GET /api/requests/:request/projects request.projects.console -> [{ project, name }] -// GET /api/requests/:request/draft request.draft.console -> { candidate, prd, requirements } (Task 4's verb) +// GET /api/requests/:request/draft request.draft.console -> { candidate, prd, requirements, snapshot } // POST /api/requests/:request/answer request.answer.console { revision, question, op, text } // POST /api/requests/:request/attach request.attach.console { revision, project, op } // POST /api/requests/:request/mode request.mode.console { revision, mode_revision, mode, op } // POST /api/requests/:request/cancel request.cancel.console { revision, op } +// POST /api/requests/:request/revoke prd.revoke.console { project, snapshot, revision, reason, op } +// Revoke uses the project's snapshot-event revision, and returns a revocation receipt rather than +// request state. Its project and snapshot are checked against the route's current request first. // Only the signed-in owner reaches this module (serve.mjs fences granted users and the tester before // it). A write needs the Console's own Origin and same-origin fetch metadata, a bounded JSON body of // exactly the fields above, and a live session. The browser never names the business, the subject or -// a verb: the verb comes from the route, the subject from the session. Nothing here retries, and a -// reply that is not the state of this request is never relayed. A text answer is an answer to the +// a verb: the verb comes from the route, the subject from the session. Nothing here retries. A state +// 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 { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; -export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel))?$/; +export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke))?$/; const WRITE_VERBS = Object.freeze({ - answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', + answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console', }); const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console' }); // The exact key set of each write body. A key outside it, such as `request`, `business` or `subject`, @@ -26,6 +29,7 @@ const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'reques const FIELDS = Object.freeze({ answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'], mode: ['revision', 'mode_revision', 'mode', 'op'], cancel: ['revision', 'op'], + revoke: ['project', 'snapshot', 'revision', 'reason', 'op'], }); // The endpoint contract. Each refusal is one of these, never a 200. export const REQUEST_REFUSALS = Object.freeze({ @@ -47,6 +51,11 @@ export const REQUEST_REFUSALS = Object.freeze({ 'request-project-refused': [403, 'That project cannot be picked for this request.'], 'request-operation-conflict': [409, 'That operation id was already used for a different change.'], 'prd-candidate-required': [409, 'No PRD draft has been submitted for this request yet.'], + 'prd-candidate-stale': [409, 'The PRD or a requirement changed. A new submission is needed.'], + 'stale-spec': [409, 'The confirmed intent changed. A new PRD submission is needed.'], + 'requirement-snapshot-refused': [403, 'That approved snapshot does not belong to this request.'], + 'requirement-snapshot-revoked': [409, 'That approved snapshot has been revoked. Read the request again.'], + 'processing-halted': [409, 'Processing is stopped. This operation was refused.'], 'draft-unavailable': [503, 'The draft PRD view is not available yet.'], unavailable: [503, 'The request is unavailable. Try again later.'], 'outcome-unknown': [503, 'The change may have reached the bus. Read the request before trying again.'], @@ -64,6 +73,9 @@ const BUS_CODES = { 'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused', 'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request', 'prd-candidate-required': 'prd-candidate-required', + 'prd-candidate-stale': 'prd-candidate-stale', 'stale-spec': 'stale-spec', + 'requirement-snapshot-refused': 'requirement-snapshot-refused', 'requirement-snapshot-revoked': 'requirement-snapshot-revoked', + 'processing-halted': 'processing-halted', 'read-only': 'read-only', }; const BODY_MAX = 4096; const ANSWER_BODY_MAX = 65536; @@ -108,13 +120,23 @@ function fields(action, value) { } if (action === 'attach' && (typeof value.project !== 'string' || !ID.test(value.project))) return null; if (action === 'mode' && (!positive(value.mode_revision) || !MODES.includes(value.mode))) return null; + if (action === 'revoke' && (typeof value.project !== 'string' || !ID.test(value.project) || typeof value.snapshot !== 'string' || !ID.test(value.snapshot) || + typeof value.reason !== 'string' || !value.reason.trim() || value.reason.length > 1024 || value.reason.includes('\0'))) return null; return Object.fromEntries(FIELDS[action].map(k => [k, value[k]])); } // A reply is relayed only when it is the state of this request: version 1, this id, a positive // revision and a phase. const state = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.version === 1 && v.request === id && positive(v.revision) && typeof v.phase === 'string'; -// The draft is relayed only when its candidate names this request and it carries the PRD text and its requirements. -const draft = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.requirements); +// Empty material still carries the counter after invalidation. Never infer it from request revision. +const draft = (v, id) => { + if (!v || typeof v !== 'object' || !Array.isArray(v.requirements) || !v.snapshot || !Number.isSafeInteger(v.snapshot.revision) || v.snapshot.revision < 0 || + !(v.snapshot.id === null || typeof v.snapshot.id === 'string' && ID.test(v.snapshot.id) && positive(v.snapshot.revision))) return false; + if (v.candidate === null) return v.prd === null && v.requirements.length === 0; + return v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.candidate.requirements) && + v.candidate.requirements.length === v.requirements.length && v.requirements.every((r, i) => r && v.candidate.requirements[i] && r.id === v.candidate.requirements[i].id && + r.title === v.candidate.requirements[i].title && typeof r.text === 'string'); +}; +const revoked = (v, args) => v && v.kind === 'revoked' && v.snapshot === args.snapshot && v.revision === args.revision + 1 && typeof v.cause === 'string'; const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object' && typeof p.project === 'string' && typeof p.name === 'string'); // `session` is the live sign-in session object this request passed, and `live()` reads it again right @@ -164,9 +186,19 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s if (!args) return refuse('invalid-request'); const tail = ` revision=${args.revision}`; if (live() !== session) return refuse('sign-in-required', tail); + if (action === 'revoke') { + if (!requestRead) return refuse('unavailable', tail); + let binding; + try { binding = await requestRead(READ_VERBS.get, { request: id }, { subject }); } + catch (err) { return refuse(busCode(err, 'unavailable'), tail); } + if (!state(binding, id)) return refuse('unavailable', tail); + if (binding.project !== args.project || binding.snapshot !== args.snapshot) return refuse('requirement-snapshot-refused', tail); + if (live() !== session) return refuse('sign-in-required', tail); + } let reply; - try { reply = await requestWrite(WRITE_VERBS[action], { request: id, ...args }, { subject }); } catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); } - if (!state(reply, id)) return refuse('outcome-unknown', tail); + try { reply = await requestWrite(WRITE_VERBS[action], action === 'revoke' ? args : { request: id, ...args }, { subject }); } + catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); } + if (action === 'revoke' ? !revoked(reply, args) : !state(reply, id)) return refuse('outcome-unknown', tail); line(`${tail} outcome=ok`); return json(res, 200, reply); } diff --git a/packages/webui/tests/request-browser.test.mjs b/packages/webui/tests/request-browser.test.mjs index c54b68197..f25aa53c4 100644 --- a/packages/webui/tests/request-browser.test.mjs +++ b/packages/webui/tests/request-browser.test.mjs @@ -22,6 +22,17 @@ const PICKING = state({ route: 'existing', wait: wait('setup', 'request-existing const QUESTION = { id: 'q-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'Command line' }], answer: null }; const ASKING = state({ revision: 7, phase: 'discovery', project: 'factory', chat_project: 'c1', mode: 'guided', mode_revision: 1, question: QUESTION, wait: wait('question', 'question', 'q-1', { resume_phase: 'discovery', observed_revision: 7 }) }); +const CANDIDATE = { version: 1, request: R, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.2.md', + prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, + requirements: [{ id: 'REQ-TIMER-1', title: 'Timer', path: 'requirements/REQ-TIMER-1.json', blob: 'd'.repeat(40) }] }; +const DRAFT = { candidate: CANDIDATE, prd: '# Timer 1.2\n' + HOSTILE, + requirements: [{ recordVersion: 1, id: 'REQ-TIMER-1', title: 'Timer', text: 'Counts down\n' + HOSTILE, status: 'draft' }], snapshot: { id: null, revision: 2 } }; +const REVIEWING = state({ ...ASKING, revision: 11, phase: 'prd-review', question: null, candidate: CANDIDATE, + wait: wait('decision', 'prd-approval', D, { resume_phase: 'prd-review', observed_revision: 11 }) }); +const APPROVED = { ...REVIEWING, revision: 14, phase: 'planning', snapshot: 'prd-approved', wait: wait('setup', 'request-planning-unavailable', R) }; +const ACTIVE_DRAFT = { ...DRAFT, snapshot: { id: 'prd-approved', revision: 3 } }; +const PRD_INBOX = [{ id: D, action: 'prd.approve', question: 'Approve product PRD 1.2 at ' + CANDIDATE.records_commit + '?', + console: { kind: 'decision', digest: 'e'.repeat(64), options: [{ key: 'yes', text: 'Approve this exact product PRD version' }, { key: 'no', text: 'Request changes' }] } }]; const ROW = { id: M, body: 'Build me a countdown timer', from_role: 'human', at: '2026-11-03T00:00:00Z', request: R }; const INBOX = [{ id: D, question: 'Confirm the resource plan.', options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }], console: { kind: 'decision', digest: 'd'.repeat(64), options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }] } }]; @@ -30,6 +41,7 @@ const stubFor = boot => `(() => { const boot = JSON.parse(sessionStorage.getItem('__boot') || '{}'); const real = fetch.bind(window); window.__rreads = []; window.__rwrites = []; window.__rAnswers = []; + window.__drafts = boot.drafts || {}; window.__draftStatus = {}; window.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || []; window.__readStatus = {}; window.__stubReady = true; @@ -51,9 +63,13 @@ const stubFor = boot => `(() => { return json(a.body ?? a.set, a.status || 200); } window.__rreads.push(path); - const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects))?$/.exec(path); + const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects|draft))?$/.exec(path); if (!m) return json({ error: 'request-not-found' }, 404); if (window.__readStatus[m[1]]) return json({ error: 'sign-in-required', message: 'Sign in.' }, window.__readStatus[m[1]]); + if (m[2] === 'draft') { + if (window.__draftHold) await new Promise(r => window.__releaseDraft = r); + return window.__draftStatus[m[1]] ? json(window.__draftStatus[m[1]].body, window.__draftStatus[m[1]].status) : json(window.__drafts[m[1]]); + } if (m[2] === 'projects') return window.__projectsFail ? json({ error: 'unavailable', message: 'x' }, 503) : json(window.__projects); const s = window.__states[m[1]]; return s ? json(s) : json({ error: 'request-not-found', message: 'No such request.' }, 404); @@ -101,7 +117,7 @@ async function setup(t, { boot = {}, suffix = '' } = {}) { } await w('document.querySelector(".chat-conversation")'); }; - await w('document.querySelector(".chat-conversation")'); + await w('window.__stubReady === true && document.readyState === "complete" && document.querySelector(".chat-conversation")'); return { b, w, set, click, text, count, writes, answer, reload, sleep }; } const P = `.request-panel[data-request="${R}"]`; @@ -383,3 +399,160 @@ test('request panel: sign-out clears the panel and a late write response changes await out.b.evaluate(`window.__readStatus['${R}']=401;document.querySelector('${P} [data-request-reload]').click();true`); await out.w('document.body.classList.contains("signed-out")'); }); + + +test('PRD panel: exact candidate, full requirements and version are inert, visible before approval and read-only without writes', async t => { + const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); + await e.w(`document.querySelector('${P} .request-prd-text')`); + assert.equal(await e.text(`${P} .request-prd-text`), '# Timer 1.2\n' + HOSTILE.replace('\u001b', '\\u001b').replace('\u202e', '\\u202e')); + assert.match(await e.text(`${P} .request-prd`), /1\.2/); + for (const value of [CANDIDATE.records_commit, CANDIDATE.prd_path, CANDIDATE.prd_blob, CANDIDATE.spec_commit, 'REQ-TIMER-1', CANDIDATE.requirements[0].blob]) + assert.ok((await e.text(`${P} .request-prd`)).includes(value), value); + assert.ok((await e.text(`${P} .request-requirements`)).includes('Counts down')); + assert.equal(await e.count(`${P} img, ${P} script`), 0); + assert.equal(await e.b.evaluate('window.injected'), undefined); + assert.deepEqual(await e.writes(), []); + assert.equal(await e.count(`${P} [data-request-choice]`), 2); + const ro = await setup(t, { suffix: '?readonly', boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT }, inbox: PRD_INBOX } }); + await ro.w(`document.querySelector('${P} .request-prd-text')`); + assert.equal(await ro.count(`${P} [data-request-choice], ${P} [data-request-revoke]`), 0); + assert.match(await ro.text(`${P} .request-snapshot`), /prd-approved/); + assert.deepEqual(await ro.writes(), []); +}); + +test('PRD panel: guided approval names the displayed exact version through the existing one-use confirmation', async t => { + const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); + await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`); + await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve this exact product PRD version', question: PRD_INBOX[0].question, confirmation: 'prd-token' } }); + await e.click(`${P} [data-request-choice="yes"]`); + await e.w(`document.querySelector('${P} [data-request-confirm]')`); + assert.match(await e.text(`${P} .request-confirm`), /1\.2/); + assert.ok((await e.text(`${P} .request-confirm`)).includes(CANDIDATE.records_commit)); + assert.equal((await e.writes()).length, 1, 'preview never resolves'); + await e.b.evaluate(`window.__states['${R}']=${JSON.stringify(APPROVED)};window.__drafts['${R}']=${JSON.stringify(ACTIVE_DRAFT)};true`); + await e.answer({ body: { id: D, op: 'resolved', choice: 'yes' } }); + await e.click(`${P} [data-request-confirm]`); + await e.w(`document.querySelector('${P} .request-snapshot')?.textContent.includes('prd-approved')`); + assert.deepEqual((await e.writes()).map(x => [x.path, x.body]), [ + [`/api/decisions/${D}/confirmation`, { choice: 'yes' }], [`/api/decisions/${D}/resolve`, { choice: 'yes', confirmation: 'prd-token' }], + ]); + assert.match(await e.text(`${P} .request-receipt`), /Recorded/); +}); + +test('PRD panel: autonomous approval stays with its arbiter and an unavailable, stale or mismatched draft offers no approval', async t => { + const e = await setup(t, { boot: { states: { [R]: { ...REVIEWING, mode: 'autonomous' } }, drafts: { [R]: DRAFT }, inbox: [] } }); + await e.w(`document.querySelector('${P} .request-prd-text')`); + assert.equal(await e.count(`${P} [data-request-choice]`), 0); + for (const [status, error] of [[403, 'human-required'], [409, 'prd-candidate-stale'], [409, 'request-revision-conflict'], [503, 'unavailable']]) { + await e.b.evaluate(`window.__draftStatus['${R}']={status:${status},body:{error:'${error}',message:'Draft refused.'}};true`); + await e.click(`${P} [data-request-reload]`); + await e.w(`document.querySelector('${P} .request-prd-error')?.textContent.includes('${error}')`); + assert.equal(await e.count(`${P} .request-prd-text, ${P} [data-request-choice], ${P} [data-request-revoke]`), 0); + } + await e.b.evaluate(`delete window.__draftStatus['${R}'];window.__drafts['${R}']=${JSON.stringify({ ...DRAFT, candidate: { ...CANDIDATE, records_commit: 'f'.repeat(40) } })};window.__inbox=${JSON.stringify(PRD_INBOX)};true`); + await e.click(`${P} [data-request-reload]`); + await e.w(`document.querySelector('${P} .request-prd-error')`); + assert.equal(await e.count(`${P} [data-request-choice]`), 0, 'a different candidate cannot be approved as the request candidate'); + assert.deepEqual(await e.writes(), []); +}); + +test('PRD panel: refreshing a changed candidate drops an open confirmation', async t => { + const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); + await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`); + await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve', question: PRD_INBOX[0].question, confirmation: 'stale-token' } }); + await e.click(`${P} [data-request-choice="yes"]`); + await e.w(`document.querySelector('${P} [data-request-confirm]')`); + await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...ASKING, revision: 12 })};true`); + await e.click(`${P} [data-request-reload]`); + await e.w(`!document.querySelector('${P} [data-request-confirm]')`); + assert.equal((await e.writes()).length, 1); +}); + +test('PRD revoke: asks for a reason and confirmation, sends the snapshot counter and reads the resulting empty draft', async t => { + const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); + await e.w(`document.querySelector('${P} [data-request-revoke]')`); + await e.click(`${P} [data-request-revoke]`); + assert.deepEqual(await e.writes(), []); + await e.click(`${P} [data-request-revoke-confirm]`); + assert.deepEqual(await e.writes(), []); + assert.match(await e.text(`${P} .request-receipt`), /reason/i); + await e.set(`${P} .request-revoke textarea`, 'x'.repeat(1025)); + await e.click(`${P} [data-request-revoke-confirm]`); + assert.deepEqual(await e.writes(), []); + await e.set(`${P} .request-revoke textarea`, 'Replace the requirements'); + await e.b.evaluate(`window.__drafts['${R}']=${JSON.stringify({ candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 4 } })};true`); + await e.answer({ set: { ...ASKING, revision: 15, question: null, candidate: null, snapshot: null }, body: { snapshot: 'prd-approved', revision: 4, kind: 'revoked', cause: 'host' } }); + await e.click(`${P} [data-request-revoke-confirm]`); + await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('revoked') && !document.querySelector('${P} [data-request-revoke]')`); + const [sent] = await e.writes(); + assert.equal(sent.path, `/api/requests/${R}/revoke`); + assert.deepEqual({ ...sent.body, op: undefined }, { project: 'factory', snapshot: 'prd-approved', revision: 3, reason: 'Replace the requirements', op: undefined }); + assert.notEqual(sent.body.revision, APPROVED.revision); + assert.match(sent.body.op, /^revoke-[0-9a-f]{24}$/); + assert.equal(sent.redirect, 'manual'); + await e.w(`document.querySelector('${P} .request-prd-empty')`); +}); + +test('PRD revoke: stale, refused, malformed and lost outcomes never show success or retry', async t => { + const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); + await e.w(`document.querySelector('${P} [data-request-revoke]')`); + for (const [reply, receipt] of [ + [{ status: 409, body: { error: 'request-revision-conflict', message: 'Snapshot changed.' } }, 'request-revision-conflict'], + [{ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Snapshot refused.' } }, 'requirement-snapshot-refused'], + [{ status: 503, body: { error: 'unavailable', message: 'Revoke refused.' } }, 'unavailable'], + [{ status: 503, body: { error: 'outcome-unknown', message: 'The revoke may have reached the bus.' } }, 'unknown'], + [{ body: { snapshot: 'wrong', revision: 4, kind: 'revoked' } }, 'unknown'], [{ reject: true }, 'unknown'], + ]) { + if (await e.count(`${P} [data-request-revoke]`)) await e.click(`${P} [data-request-revoke]`); + await e.set(`${P} .request-revoke textarea`, 'Replace it'); + await e.answer(reply); + const n = (await e.writes()).length; + await e.click(`${P} [data-request-revoke-confirm]`); + await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('${receipt}')`); + assert.doesNotMatch(await e.text(`${P} .request-receipt`), /^Approved snapshot revoked/); + assert.match(await e.text(`${P} .request-receipt`), receipt === 'unknown' ? /^Outcome unknown/ : /^Not revoked/); + await e.w(`!document.querySelector('${P} [data-request-reload]')?.disabled`); + assert.equal((await e.writes()).length, n + 1); + } +}); + +test('PRD revoke: a stale refusal rereads the project counter before the next deliberate click', async t => { + const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); + await e.w(`document.querySelector('${P} [data-request-revoke]')`); + await e.click(`${P} [data-request-revoke]`); + await e.set(`${P} .request-revoke textarea`, 'Replace it'); + await e.b.evaluate(`window.__drafts['${R}'].snapshot.revision=5;true`); + await e.answer({ status: 409, body: { error: 'request-revision-conflict', message: 'Read the snapshot again.' } }); + await e.click(`${P} [data-request-revoke-confirm]`); + await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('request-revision-conflict') && !document.querySelector('${P} [data-request-reload]')?.disabled`); + assert.equal((await e.writes()).length, 1); + await e.answer({ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Refused.' } }); + await e.click(`${P} [data-request-revoke-confirm]`); + await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('requirement-snapshot-refused')`); + const writes = await e.writes(); + assert.deepEqual(writes.map(x => x.body.revision), [3, 5]); + assert.notEqual(writes[1].body.op, writes[0].body.op); +}); + +test('PRD panel: sign-out clears candidate material and ignores a late draft read', async t => { + const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); + await e.w(`document.querySelector('${P} .request-prd-text')`); + await e.b.evaluate('window.__draftHold=true;true'); + await e.click(`${P} [data-request-reload]`); + await e.w('typeof window.__releaseDraft === "function"'); + await e.b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseDraft();true'); + await e.w('document.body.classList.contains("signed-out")'); + assert.equal(await e.count('.request-prd-text, [data-request-revoke], [data-request-choice]'), 0); + assert.doesNotMatch(await e.b.evaluate('document.body.innerText'), /Counts down|Timer 1\.2/); + assert.deepEqual(await e.writes(), []); +}); + +test('PRD panel: a successful mode change immediately clears the invalidated draft and revoke control', async t => { + const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); + await e.w(`document.querySelector('${P} [data-request-revoke]')`); + await e.answer({ set: { ...ASKING, revision: 15, question: null, mode: 'autonomous', mode_revision: 2 } }); + await e.click(`${P} [data-request-mode="autonomous"]`); + await e.w(`document.querySelector('${P} .request-mode')?.textContent.includes('autonomous')`); + assert.equal(await e.count(`${P} .request-prd-text, ${P} .request-snapshot, ${P} [data-request-revoke]`), 0); + assert.equal((await e.writes()).length, 1); +}); diff --git a/packages/webui/tests/request-http.test.mjs b/packages/webui/tests/request-http.test.mjs index 76b7a31e4..b1ddc9d7d 100644 --- a/packages/webui/tests/request-http.test.mjs +++ b/packages/webui/tests/request-http.test.mjs @@ -30,7 +30,14 @@ const BODIES = { attach: { revision: 5, project: 'old', op: 'att-1' }, mode: { revision: 5, mode_revision: 1, mode: 'guided', op: 'mode-1' }, cancel: { revision: 5, op: 'cancel-1' }, + revoke: { project: 'old', snapshot: 'prd-active', revision: 3, reason: 'Replace the draft', op: 'revoke-1' }, }; +const CANDIDATE = { version: 1, request: 'r1', project: 'old', prd_version: '1.0', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.0.md', + prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, + requirements: [{ id: 'R1', title: 'Timer', path: 'requirements/R1.json', blob: 'd'.repeat(40) }] }; +const DRAFT = { candidate: CANDIDATE, prd: '# PRD\n', requirements: [{ recordVersion: 1, id: 'R1', title: 'Timer', text: 'Counts down', status: 'draft' }], snapshot: { id: 'prd-active', revision: 3 } }; +const ACTIVE = { ...STATE, candidate: CANDIDATE, snapshot: 'prd-active', phase: 'planning' }; +const REVOKED = { snapshot: 'prd-active', revision: 4, kind: 'revoked', cause: 'opaque host receipt' }; const VERBS = { answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console' }; function request({ method = 'POST', body = {}, headers = {}, raw } = {}) { @@ -71,8 +78,8 @@ const refused = (res, code) => { assert.equal(res.headers['cache-control'], 'no-store'); }; -test('the route pattern takes a request id and one of five suffixes, and nothing else', () => { - for (const ok of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft', '/api/requests/r1/answer', '/api/requests/r1/attach', '/api/requests/r1/mode', '/api/requests/r1/cancel']) +test('the route pattern takes a request id and a known suffix, and nothing else', () => { + for (const ok of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft', '/api/requests/r1/answer', '/api/requests/r1/attach', '/api/requests/r1/mode', '/api/requests/r1/cancel', '/api/requests/r1/revoke']) assert.match(ok, REQUEST_ROUTE); for (const bad of ['/api/requests', '/api/requests/', '/api/requests/r1/other', '/api/requests/r1/answer/x', '/api/requests/a/b/answer']) assert.doesNotMatch(bad, REQUEST_ROUTE); }); @@ -113,7 +120,7 @@ test('the draft read is Task 4\'s verb: it fails closed on a bus without it and const s = setup({ verbs: CONSOLE_VERBS.filter(v => v !== 'request.draft.console') }); refused(await s.get('/api/requests/r1/draft'), 'draft-unavailable'); assert.equal(s.reads.length, 0, 'no bus call and no invented PRD content'); - const record = { candidate: { request: 'r1', project: 'old', prd_version: '1.0' }, prd: '# PRD\n', requirements: [{ id: 'R1', text: 'Counts down' }] }; + const record = DRAFT; const t = setup({ read: async () => record }); const ok = await t.get('/api/requests/r1/draft'); assert.equal(ok.status, 200, ok.text); @@ -290,7 +297,7 @@ test('without a write transport the listener reads but every intake write is rea const config = await env.get(cookie, '/api/config'); assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, false); assert.equal((await env.get(cookie, '/api/requests/r1')).status, 200); - for (const action of ['answer', 'attach', 'mode', 'cancel']) { + for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await env.post(cookie, `/api/requests/r1/${action}`, BODIES[action]); assert.deepEqual([r.status, r.body.error], [403, 'read-only'], action); } @@ -323,7 +330,7 @@ test('a signed-out caller, a denied origin and a granted user all stop before th const alice = await env.signIn('alice-sub'); const config = await env.get(alice, '/api/config'); assert.equal(config.body.user, true); assert.equal(config.body.requests ?? false, false); assert.equal(config.body.requestWrites ?? false, false); - for (const action of ['answer', 'attach', 'mode', 'cancel']) { + for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await env.post(alice, `/api/requests/r1/${action}`, BODIES[action]); assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], action); } @@ -342,7 +349,7 @@ const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => { const r = await call(port, { path, host, headers: extra }); seen.push([path, r.status, r.body.error]); } - for (const action of ['answer', 'attach', 'mode', 'cancel']) { + for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host, headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) }); seen.push([action, r.status, r.body.error]); @@ -353,7 +360,7 @@ const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => { return seen; }; const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'], - ['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['wrong-method', 405, 405]]; + ['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['revoke', 403, 'read-only'], ['wrong-method', 405, 405]]; test('without --sso a request URL is refused by the listener and the process keeps serving', async t => { const crashes = []; @@ -407,3 +414,108 @@ test('the request verbs are the console wrapper\'s, and the Console names no own const http = readFileSync(new URL('../src/request-http.mjs', import.meta.url), 'utf8'); assert.doesNotMatch(http, /['"]request\.(get|projects|answer|attach|mode|cancel|propose|open|question)['"]/, 'the HTTP seam names only .console verbs'); }); + + +test('revoke binds the route to the live request project and snapshot, sends only catalogue fields, and validates its receipt', async () => { + const s = setup({ read: async () => ACTIVE, write: async () => REVOKED }); + const res = await s.post('revoke'); + assert.equal(res.status, 200, res.text); + assert.deepEqual(res.json(), REVOKED); + assert.deepEqual(s.reads, [{ verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }]); + assert.deepEqual(s.writes, [{ verb: 'prd.revoke.console', args: BODIES.revoke, ctx: { subject: SUBJECT } }]); + assert.doesNotMatch(s.lines.join('\n'), /Replace the draft|opaque host receipt/); + for (const body of [ + { ...BODIES.revoke, project: 'other' }, { ...BODIES.revoke, snapshot: 'prd-other' }, + ]) { + const bound = setup({ read: async () => ACTIVE }); + refused(await bound.post('revoke', body), 'requirement-snapshot-refused'); + assert.equal(bound.writes.length, 0); + } + for (const reply of [null, { ...ACTIVE, request: 'r2' }, { ...ACTIVE, snapshot: null }]) { + const bound = setup({ read: async () => reply }); + const out = await bound.post('revoke'); + assert.ok(out.status >= 400 && out.status !== 200, out.text); + assert.equal(bound.writes.length, 0); + } + for (const reply of [null, ACTIVE, { ...REVOKED, snapshot: 'other' }, { ...REVOKED, kind: 'activated' }, { ...REVOKED, revision: 3 }]) { + const bad = setup({ read: async () => ACTIVE, write: async () => reply }); + refused(await bad.post('revoke'), 'outcome-unknown'); + assert.equal(bad.writes.length, 1); + } +}); + +test('revoke enforces body, origin, transport and current-session gates before any write', async () => { + const path = '/api/requests/r1/revoke', s = setup({ read: async () => ACTIVE }); + for (const body of [ + { ...BODIES.revoke, request: 'r2' }, { ...BODIES.revoke, business: 'demo' }, { ...BODIES.revoke, subject: SUBJECT }, + { ...BODIES.revoke, revision: 0 }, { ...BODIES.revoke, project: '../x' }, { ...BODIES.revoke, snapshot: '' }, + { ...BODIES.revoke, reason: '' }, { ...BODIES.revoke, reason: ' ' }, { ...BODIES.revoke, reason: 'x'.repeat(1025) }, { ...BODIES.revoke, reason: 'a\0b' }, + { ...BODIES.revoke, op: '' }, + ]) refused(await s.post('revoke', body), 'invalid-request'); + refused(await s.run(path, request({ raw: '{' })), 'invalid-request'); + refused(await s.run(path, request({ body: BODIES.revoke, headers: { 'content-type': ['text/plain'] } })), 'invalid-request'); + refused(await s.run(path, request({ raw: 'x'.repeat(4097) })), 'request-too-large'); + refused(await s.run(path, request({ method: 'GET' })), 'method-not-allowed'); + refused(await s.run(path, request({ body: BODIES.revoke }), { requestWrite: null }), 'read-only'); + refused(await s.run(path, request({ body: BODIES.revoke }), { requestRead: null }), 'unavailable'); + refused(await s.run(path, request({ body: BODIES.revoke }), { session: null }), 'sign-in-required'); + for (const headers of [{ origin: null }, { origin: ['https://evil.test'] }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['cross-site'] }, { 'sec-fetch-site': null }]) + refused(await s.run(path, request({ body: BODIES.revoke, headers })), 'cross-origin'); + s.current = null; + refused(await s.post('revoke'), 'sign-in-required'); + assert.equal(s.writes.length, 0); + // Sign-out while the route's binding read is in flight must still stop the write. + const late = setup({ read: async () => { late.current = null; return ACTIVE; } }); + refused(await late.post('revoke'), 'sign-in-required'); + assert.equal(late.writes.length, 0); +}); + +test('draft panel data relays empty snapshots and refuses malformed counters and stale reads', async () => { + const empty = { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } }; + const s = setup({ read: async () => empty }); + assert.deepEqual((await s.get('/api/requests/r1/draft')).json(), empty); + for (const data of [{ ...DRAFT, snapshot: null }, { ...DRAFT, snapshot: { id: 'prd-active', revision: -1 } }, + { ...DRAFT, snapshot: { id: 'prd-active', revision: 0 } }, { ...DRAFT, snapshot: { id: null, revision: '3' } }, + { ...DRAFT, requirements: [{ ...DRAFT.requirements[0], id: 'other' }] }]) { + const bad = setup({ read: async () => data }); + refused(await bad.get('/api/requests/r1/draft'), 'unavailable'); + } + for (const code of ['prd-candidate-stale', 'stale-spec', 'request-revision-conflict']) { + const bad = setup({ read: async () => { throw new BusReadError(code); } }); + const res = await bad.get('/api/requests/r1/draft'); + assert.equal(res.status, 409, res.text); assert.equal(res.json().error, code); + } +}); + +test('revoke refusals including stale revisions never become success or retry', async () => { + for (const [code, status] of [['request-revision-conflict', 409], ['request-operation-conflict', 409], ['requirement-snapshot-refused', 403], + ['requirement-snapshot-revoked', 409], ['human-required', 403], ['processing-halted', 409], ['read-only', 403]]) { + const s = setup({ read: async () => ACTIVE, write: async () => { throw new BusReadError(code); } }); + const res = await s.post('revoke'); + assert.equal(res.status, status, res.text); assert.equal(res.json().error, code); + assert.equal(s.writes.length, 1); + } +}); + +test('the signed-in owner draft and revoke listener use the snapshot revision and preserve stale refusals', async t => { + const writes = []; + const env = await listener(t, { + requestRead: async verb => verb === 'request.draft.console' ? DRAFT : ACTIVE, + requestWrite: async (verb, args, ctx) => { + writes.push({ verb, args, ctx }); + if (args.revision !== 3) throw new BusReadError('request-revision-conflict'); + return REVOKED; + }, + }); + const cookie = await env.signIn(); + const draft = await env.get(cookie, '/api/requests/r1/draft'); + assert.equal(draft.status, 200); assert.deepEqual(draft.body, DRAFT); + const stale = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: 2 }); + assert.deepEqual([stale.status, stale.body.error], [409, 'request-revision-conflict']); + const ok = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: draft.body.snapshot.revision }); + assert.equal(ok.status, 200); assert.deepEqual(ok.body, REVOKED); + assert.equal(writes.length, 2, 'no implicit retry'); + assert.equal(writes[1].verb, 'prd.revoke.console'); + assert.deepEqual(writes[1].args, BODIES.revoke); + assert.equal(writes[1].ctx.subject.sub, 'user-a'); +});