feat(console): show the PRD draft and revoke an approved snapshot (#1839)

This commit is contained in:
jason.woltje committed 2026-11-04 11:54:11 -06:00
1 parent b5a58b48b4
commit 68bef84fa7
10 files changed
+563 -54

No files matched your search

+55
View File
@@ -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`, 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. 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. 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.
+2
View File
@@ -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-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-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 — 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.
+5 -3
View File
@@ -397,11 +397,13 @@ export function createRequestLifecycle({ journal, services }) {
draftConsole: async (identity, args) => { draftConsole: async (identity, args) => {
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request'); exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft'); const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft');
if (!state.candidate) refuse('prd-candidate-required'); const material = state.candidate ? await services.readCandidate(state.candidate, state.business)
const material = await services.readCandidate(state.candidate, state.business); : { candidate: null, prd: null, requirements: [] };
const current = stateOf(state.business, state.request); authorize(identity, current, 'draft'); const current = stateOf(state.business, state.request); authorize(identity, current, 'draft');
if (current.revision !== state.revision) refuse('request-revision-conflict'); 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, }, resume, catalog: services.catalog,
decisionClosed: (business, decision) => { decisionClosed: (business, decision) => {
if (closing) return Promise.resolve(); if (closing) return Promise.resolve();
@@ -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' }); 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 => { test('3p to 3r migration preserves existing request rows and the append-only guards', async t => {
const { readFileSync, chmodSync } = await import('node:fs'); const { readFileSync, chmodSync } = await import('node:fs');
const { Store, schemaDigest } = await import('../src/store.mjs'); const { Store, schemaDigest } = await import('../src/store.mjs');
+30 -5
View File
@@ -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 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 `--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 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 `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 these fields and a live session, spawning `console-cli.mjs` with the signed-in
subject (the page never names a business, subject or verb): subject (the page never names a business, subject or verb):
`/answer {revision, question, op, text}` (64 KB body, 8000 characters of text), `/answer {revision, question, op, text}` (64 KB body, 8000 characters of text),
`/attach {revision, project, op}`, `/mode {revision, mode_revision, mode, op}`, `/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 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) retried. Choosing the mode (new autonomous, new guided, or an existing project)
answers the selection decision through the same confirmation endpoints as the 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 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 again with the same operation id, so the bus records it once. Sign-out clears
the panels and the stored answers. `GET /api/requests/<request>/draft` relays the panels and the stored answers. `GET /api/requests/<request>/draft` relays
the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`) when the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`,
its candidate names that request, `prd-candidate-required` (409) before a PRD is `snapshot`). Task 4 displays the exact PRD version, full PRD and requirement
submitted, and `draft-unavailable` (503) on a bus whose verb list lacks the verb. 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 - 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. "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, - A task's list row can only say its current state came from a Vikunja read,
+3
View File
@@ -62,3 +62,6 @@
.request-panel .btn { margin-block: .15rem; } .request-panel .btn { margin-block: .15rem; }
.request-panel [aria-pressed="true"] { outline: 2px solid var(--action); outline-offset: -2px; } .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-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; }
+109 -27
View File
@@ -9,7 +9,7 @@
- Nothing is sent twice by this page. A refused or lost write is reported and left for a deliberate - 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 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. 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. /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. */ - 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 positive = n => Number.isSafeInteger(n) && n > 0;
const text = v => typeof v === 'string'; 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 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(); const panels = new Map();
let signedOut = false, timer = null, config = null, loading = null; let signedOut = false, timer = null, config = null, loading = null;
@@ -82,8 +97,8 @@
let p = panels.get(id); let p = panels.get(id);
if (!p) { if (!p) {
const stored = load(id); 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: '', 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, draft: stored?.text ?? '' } }; ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
panels.set(id, p); panels.set(id, p);
} }
return 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.'; 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) { 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; if (!p.state || s.revision >= p.state.revision) p.state = s;
p.missing = false; p.failed = false; p.stale = false; p.missing = false; p.failed = false; p.stale = false;
reconcile(p); reconcile(p);
@@ -126,14 +146,37 @@
return `<p class="request-wait">${line}</p>`; return `<p class="request-wait">${line}</p>`;
} }
function prdHtml(p, busy, can) {
if (p.materialError) return `<p class="request-prd-error" role="status">${txt(p.materialError)} Approval and revocation are unavailable. Use Read again.</p>`;
const m = p.material;
if (!m) return '';
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>
<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) {
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
? `<form class="request-revoke-form" novalidate><p>Revoke this approved snapshot? Pending work will wait for replacement authority. Existing evidence is kept.</p>
<label for="chat-request-revoke-${esc(p.id)}">Reason for revocation</label><textarea id="chat-request-revoke-${esc(p.id)}" rows="3"${busy}>${esc(p.ui.reason)}</textarea>
<button type="submit" class="btn" data-request-revoke-confirm${busy}>${p.ui.pending === 'revoke' ? 'Revoking…' : 'Confirm revocation'}</button>
<button type="button" class="btn" data-request-revoke-back${busy}>Keep approval</button></form>`
: `<button type="button" class="btn" data-request-revoke${busy}>Revoke approved snapshot</button>`}</div>`;
} else h += '<p class="request-snapshot">This candidate has no active approved snapshot.</p>';
return h + '</section>';
}
function html(p) { function html(p) {
const s = p.state, ui = p.ui; const s = p.state, ui = p.ui;
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : ''; if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
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; const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null;
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>`; 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>`; if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
body += waitHtml(s); body += waitHtml(s);
body += prdHtml(p, busy, can);
if (q) { if (q) {
body += q.answer === null body += q.answer === null
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>` ? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
@@ -143,11 +186,12 @@
if (can && ui.confirm) { if (can && ui.confirm) {
const c = ui.confirm; const c = ui.confirm;
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p> body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
${c.candidate ? `<p>Exact product PRD <b>${txt(c.candidate.prd_version)}</b> at <code>${txt(c.candidate.records_commit)}</code>. The PRD and requirements are shown above.</p>` : ''}
<p class="small muted">Confirm to record this choice. This confirmation works once and lasts two minutes.</p> <p class="small muted">Confirm to record this choice. This confirmation works once and lasts two minutes.</p>
<button type="button" class="btn primary" data-request-confirm${busy}>${ui.pending === 'confirm' ? 'Answering…' : 'Confirm'}</button> <button type="button" class="btn primary" data-request-confirm${busy}>${ui.pending === 'confirm' ? 'Answering…' : 'Confirm'}</button>
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`; <button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
} else if (can && s.phase === 'selection' && p.inbox) { } else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
body += `<div class="request-choices" role="group" aria-label="Choose how to run this request"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o => body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`; `<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
} }
if (can && pickerOpen(s)) { if (can && pickerOpen(s)) {
@@ -185,32 +229,45 @@
// Redraw every mount of this request, keeping the keyboard where it was. // Redraw every mount of this request, keeping the keyboard where it was.
function draw(p) { function draw(p) {
if (out() || panels.get(p.id) !== p) return; if (out() || panels.get(p.id) !== p) return;
p.sig = signature(p);
for (const el of document.querySelectorAll('.request-mount[data-request]')) { for (const el of document.querySelectorAll('.request-mount[data-request]')) {
if (el.dataset.request !== p.id) continue; if (el.dataset.request !== p.id) continue;
const a = document.activeElement, inside = !!a && a !== document.body && el.contains(a); 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; const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
el.innerHTML = html(p); el.innerHTML = html(p);
if (!inside) continue; if (!inside) continue;
let focus = null; let focus = null;
if (area) { if (area) {
focus = el.querySelector('textarea'); focus = el.querySelector(`#${CSS.escape(area.id)}`);
if (focus) focus.setSelectionRange(area.start, area.end); if (focus) focus.setSelectionRange(area.start, area.end);
} else if (mark) focus = el.querySelector(`[${mark.name}="${CSS.escape(mark.value)}"]`); } else if (mark) focus = el.querySelector(`[${mark.name}="${CSS.escape(mark.value)}"]`);
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true }); (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. // The reads that follow a state: the mode decision the selection waits on and the project picker.
async function side(p) { async function side(p) {
const s = p.state; 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 (!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'); const { res, json } = await call('/api/bus/inbox');
if (out() || panels.get(p.id) !== p) return; 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 (row && row.console?.kind === 'decision' && options(row.console.options).length === row.console.options.length && row.console.options.length) p.inbox = row;
} }
if (pickerOpen(s)) { if (pickerOpen(s)) {
@@ -229,12 +286,13 @@
if (out() || panels.get(p.id) !== p) return; if (out() || panels.get(p.id) !== p) return;
if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); } 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 (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; else p.failed = true;
p.at = Date.now(); p.at = Date.now();
if (out() || panels.get(p.id) !== p) return; if (out() || panels.get(p.id) !== p) return;
const sig = signature(p); // The read is complete before drawing, so a successful refresh re-enables controls.
if (sig !== p.sig) { p.sig = sig; draw(p); } p.reading = null;
if (signature(p) !== p.sig) draw(p);
} finally { p.reading = null; } } finally { p.reading = null; }
})(); })();
return p.reading; return p.reading;
@@ -246,17 +304,20 @@
const { res, json } = await post(`/api/requests/${enc(p.id)}/${action}`, body); const { res, json } = await post(`/api/requests/${enc(p.id)}/${action}`, body);
if (out() || panels.get(p.id) !== p) return null; if (out() || panels.get(p.id) !== p) return null;
p.ui.pending = null; p.ui.pending = null;
if (res?.ok && isState(json, p.id)) return { ok: true, state: json }; if (res?.ok && (action === 'revoke'
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 }; ? 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 }; return { unknown: true };
} }
// The receipt and the follow-up read for one write's result. A refused or lost write is read // The receipt and the follow-up read for one write's result. A refused or lost write is read
// against, never repeated. // against, never repeated.
function settle(p, r, okText, noText) { function settle(p, r, okText, noText) {
if (!r) return false; 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 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); p.sig = signature(p); draw(p);
if (!r.ok) void read(p); if (!r.ok) void read(p);
return r.ok === true; 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'); 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 // the answer; the second records it. A refusal drops the confirmation and an unknown outcome is
// reported, never repeated. // reported, never repeated.
const DECIDE_FALLBACK = { 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.' : ''}`; const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`;
async function choose(p, key) { async function choose(p, key) {
const row = p.inbox, ui = p.ui; 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); ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p);
const { res, json } = await post(`/api/decisions/${enc(row.id)}/confirmation`, { choice: key }); const { res, json } = await post(`/api/decisions/${enc(row.id)}/confirmation`, { choice: key });
if (out() || panels.get(p.id) !== p) return; if (out() || panels.get(p.id) !== p) return;
ui.pending = null; 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)) { if (p.state.revision === revision && same(candidate, prdDecision(p.state) ? p.material?.candidate : null) &&
ui.confirm = { id: row.id, choice: key, text: json.text, question: text(json.question) ? json.question : '', confirmation: json.confirmation }; 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); draw(p);
} else { } else {
ui.receipt = notAnswered(refusal(res, json, 'confirmation-unavailable')); draw(p); ui.receipt = notAnswered(refusal(res, json, 'confirmation-unavailable')); draw(p);
@@ -332,7 +408,8 @@
} }
async function confirm(p) { async function confirm(p) {
const c = p.ui.confirm, ui = p.ui; 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); ui.pending = 'confirm'; ui.receipt = ''; draw(p);
const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); 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; if (out() || panels.get(p.id) !== p) return;
@@ -349,7 +426,7 @@
const el = button?.closest('.request-panel'); const el = button?.closest('.request-panel');
if (!button || !el || out()) return; if (!button || !el || out()) return;
const p = panels.get(el.dataset.request); 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; const d = button.dataset;
if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); } if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); }
else if ('requestChoice' in d) void choose(p, d.requestChoice); 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 }); } 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 ('requestResend' in d) void answer(p, true);
else if ('requestMode' in d) void mode(p, d.requestMode); 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 ('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 ('requestCancelKeep' in d) { p.ui.cancelAsk = false; draw(p); }
else if ('requestCancelConfirm' in d) void cancel(p); else if ('requestCancelConfirm' in d) void cancel(p);
}); });
document.addEventListener('submit', event => { 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; if (!form) return;
event.preventDefault(); event.preventDefault();
const p = panels.get(form.closest('.request-panel')?.dataset.request); 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 => { document.addEventListener('input', event => {
const area = event.target.closest?.('.request-answer textarea'); const area = event.target.closest?.('.request-answer textarea');
const p = area && panels.get(area.closest('.request-panel')?.dataset.request); const p = area && panels.get(area.closest('.request-panel')?.dataset.request);
if (p && !out()) p.ui.draft = area.value; 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 // The Console does not push request changes, so a shown panel re-reads on a slow beat while the page
+42 -10
View File
@@ -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): // 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 request.get.console -> RequestState
// GET /api/requests/:request/projects request.projects.console -> [{ project, name }] // 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/answer request.answer.console { revision, question, op, text }
// POST /api/requests/:request/attach request.attach.console { revision, project, op } // 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/mode request.mode.console { revision, mode_revision, mode, op }
// POST /api/requests/:request/cancel request.cancel.console { revision, 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 // 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 // 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 // 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 // a verb: the verb comes from the route, the subject from the session. Nothing here retries. A state
// reply that is not the state of this request is never relayed. A text answer is an answer to the // 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. // PM's question, never a PRD approval; the decision answer routes are decision-http.mjs's.
import { BusReadError } from './bus.mjs'; import { BusReadError } from './bus.mjs';
import { CONSOLE_VERBS } from '../../bus/src/broker.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({ 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' }); 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`, // 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({ const FIELDS = Object.freeze({
answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'], answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'],
mode: ['revision', 'mode_revision', 'mode', 'op'], cancel: ['revision', '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. // The endpoint contract. Each refusal is one of these, never a 200.
export const REQUEST_REFUSALS = Object.freeze({ 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-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.'], '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-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.'], 'draft-unavailable': [503, 'The draft PRD view is not available yet.'],
unavailable: [503, 'The request is unavailable. Try again later.'], 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.'], '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-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused',
'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request', 'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request',
'prd-candidate-required': 'prd-candidate-required', '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 BODY_MAX = 4096;
const ANSWER_BODY_MAX = 65536; 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 === '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 === '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]])); 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 // A reply is relayed only when it is the state of this request: version 1, this id, a positive
// revision and a phase. // 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'; 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. // Empty material still carries the counter after invalidation. Never infer it from request revision.
const draft = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.requirements); 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'); 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 // `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'); if (!args) return refuse('invalid-request');
const tail = ` revision=${args.revision}`; const tail = ` revision=${args.revision}`;
if (live() !== session) return refuse('sign-in-required', tail); 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; let reply;
try { reply = await requestWrite(WRITE_VERBS[action], { request: id, ...args }, { subject }); } catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); } try { reply = await requestWrite(WRITE_VERBS[action], action === 'revoke' ? args : { request: id, ...args }, { subject }); }
if (!state(reply, id)) return refuse('outcome-unknown', tail); 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`); line(`${tail} outcome=ok`);
return json(res, 200, reply); return json(res, 200, reply);
} }
+175 -2
View File
@@ -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 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, 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 }) }); 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 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' }], 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' }] } }]; 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 boot = JSON.parse(sessionStorage.getItem('__boot') || '{}');
const real = fetch.bind(window); const real = fetch.bind(window);
window.__rreads = []; window.__rwrites = []; window.__rAnswers = []; 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.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || [];
window.__readStatus = {}; window.__readStatus = {};
window.__stubReady = true; window.__stubReady = true;
@@ -51,9 +63,13 @@ const stubFor = boot => `(() => {
return json(a.body ?? a.set, a.status || 200); return json(a.body ?? a.set, a.status || 200);
} }
window.__rreads.push(path); 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 (!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 (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); if (m[2] === 'projects') return window.__projectsFail ? json({ error: 'unavailable', message: 'x' }, 503) : json(window.__projects);
const s = window.__states[m[1]]; const s = window.__states[m[1]];
return s ? json(s) : json({ error: 'request-not-found', message: 'No such request.' }, 404); 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('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 }; return { b, w, set, click, text, count, writes, answer, reload, sleep };
} }
const P = `.request-panel[data-request="${R}"]`; 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.b.evaluate(`window.__readStatus['${R}']=401;document.querySelector('${P} [data-request-reload]').click();true`);
await out.w('document.body.classList.contains("signed-out")'); 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);
});
+119 -7
View File
@@ -30,7 +30,14 @@ const BODIES = {
attach: { revision: 5, project: 'old', op: 'att-1' }, attach: { revision: 5, project: 'old', op: 'att-1' },
mode: { revision: 5, mode_revision: 1, mode: 'guided', op: 'mode-1' }, mode: { revision: 5, mode_revision: 1, mode: 'guided', op: 'mode-1' },
cancel: { revision: 5, op: 'cancel-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' }; 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 } = {}) { function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
@@ -71,8 +78,8 @@ const refused = (res, code) => {
assert.equal(res.headers['cache-control'], 'no-store'); assert.equal(res.headers['cache-control'], 'no-store');
}; };
test('the route pattern takes a request id and one of five suffixes, and nothing else', () => { 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']) 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); 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); 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') }); const s = setup({ verbs: CONSOLE_VERBS.filter(v => v !== 'request.draft.console') });
refused(await s.get('/api/requests/r1/draft'), 'draft-unavailable'); refused(await s.get('/api/requests/r1/draft'), 'draft-unavailable');
assert.equal(s.reads.length, 0, 'no bus call and no invented PRD content'); 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 t = setup({ read: async () => record });
const ok = await t.get('/api/requests/r1/draft'); const ok = await t.get('/api/requests/r1/draft');
assert.equal(ok.status, 200, ok.text); 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'); const config = await env.get(cookie, '/api/config');
assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, false); assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, false);
assert.equal((await env.get(cookie, '/api/requests/r1')).status, 200); 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]); const r = await env.post(cookie, `/api/requests/r1/${action}`, BODIES[action]);
assert.deepEqual([r.status, r.body.error], [403, 'read-only'], 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 alice = await env.signIn('alice-sub');
const config = await env.get(alice, '/api/config'); 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); 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]); const r = await env.post(alice, `/api/requests/r1/${action}`, BODIES[action]);
assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], 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 }); const r = await call(port, { path, host, headers: extra });
seen.push([path, r.status, r.body.error]); 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, const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host,
headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) }); headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) });
seen.push([action, r.status, r.body.error]); seen.push([action, r.status, r.body.error]);
@@ -353,7 +360,7 @@ const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => {
return seen; return seen;
}; };
const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'], 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 => { test('without --sso a request URL is refused by the listener and the process keeps serving', async t => {
const crashes = []; 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'); 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'); 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');
});