feat(console): choose a request mode, answer questions and confirm attachment with buttons (#1839)

The owner handles a new request from the Console: pick the mode (new
autonomous, new guided or an existing project), answer the project
manager's questions and confirm an attachment, with no copy-to-terminal
step.

Bus: 3p->3q append-only migration widens the request_events kinds. The PM
asks one question with request.question. The owner-only
request.answer.console, request.attach.console, request.mode.console and
request.cancel.console join the two console reads in CONSOLE_VERBS. An
answer is an attributed human.input linked to its question and request,
recorded once per op and never a PRD approval. An attach pick makes the PM
propose that project as a confirmation decision; nothing is mapped on the
pick. A mode change takes the expected revisions and leaves the bound plan
as evidence. Cancel keeps a created project.

Console: request-http.mjs serves GET /api/requests/:request, /projects and
/draft and POST /answer, /attach, /mode and /cancel with the signed-in
owner, same-origin and bounded-body checks of the decision routes.
requests.js draws the panel under the owner's chat message, answers the
mode decision through the existing confirmation endpoints, and after a lost
answer response reads the request first and resends the same op only on the
owner's click.
This commit is contained in:
jason.woltje committed 2026-11-04 11:08:47 -06:00
1 parent c098fa022c
commit e9a12beb1a
36 files changed
+2031 -75

No files matched your search

+1
View File
@@ -6016,3 +6016,4 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-04T16:35:38Z | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 before/after | Before: fast-forwarded the assigned worktree to reviewed 52b283c8a; C1 foreign working-repo retry adoption/internal object links, C2 stale records and intent commits, C3 broker serving before the activate receipt. After: a first-attempt foreign repository gets a durable refused working receipt; recovery requires this plan's ownership claim, created exclusively inside the init directory before rename, as well as the existing ref checks. Every working-repository Git child checks for internal links and alternates; bootstrap also rechecks between its recovery reads. The existing kill-and-adopt recovery remains green. Organization holdAsync keeps the existing on-disk lock through asynchronous effects and releases it on success or failure; records initialization, intent publication/add/commit and intent rollback stay protected, while the bootstrap's exact-add mapping keeps its own transaction. The complete marker is verified and the activate receipt commits before the broker reload; runtime no longer overrides pending admission, and receipt I/O failure stays fenced until a fault-free retry. RED on 52b283c8a with final tests overlaid: 79 existing tests pass and all 16 new regressions fail as expected across request-bootstrap, project-bootstrap and working (95 total, zero skips); all five supplied reproducers were ported with corrected assertions. Final gates, all zero failures/skips/cancellations: focused 103/103 (baseline 90), bus 1499/1499 (1488), business 307/307 (305), project+seat 274/274 (271), launcher+stack-release including external-release cases 286/286 (286). Gate 5 uses node --test packages/cli/tests/launcher.test.mjs packages/cli/tests/stack-release*.test.mjs because these are CLI test files, not separate packages. Corrections: an initial gate-5 command named nonexistent package directories and ran no tests; an intermediate bus run failed the newly added per-child C1 regression, also reproduced on the base and fixed before the final green rerun. An earlier candidate gate-5 run was superseded and only its own descendant processes stopped; the final run completed. No preexisting test failure was found. Evidence: /mnt/storage/scratch/tmp/qRkZh1/ (before-1..4.log, before-5-cli.log, after-1..5.log, red-final-snapshot.log, verified-files.sha256). Provider interruption recovered from complete logs and matching source/test hashes. The queue refuses linked worktrees, so the explicit controller assignment was followed without changing the canonical queue. One local commit only; no push, live-instance action, role/contract/migration edit or residual C1/C2/C3 finding. 2026-11-04T16:35:38Z | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 before/after | Before: fast-forwarded the assigned worktree to reviewed 52b283c8a; C1 foreign working-repo retry adoption/internal object links, C2 stale records and intent commits, C3 broker serving before the activate receipt. After: a first-attempt foreign repository gets a durable refused working receipt; recovery requires this plan's ownership claim, created exclusively inside the init directory before rename, as well as the existing ref checks. Every working-repository Git child checks for internal links and alternates; bootstrap also rechecks between its recovery reads. The existing kill-and-adopt recovery remains green. Organization holdAsync keeps the existing on-disk lock through asynchronous effects and releases it on success or failure; records initialization, intent publication/add/commit and intent rollback stay protected, while the bootstrap's exact-add mapping keeps its own transaction. The complete marker is verified and the activate receipt commits before the broker reload; runtime no longer overrides pending admission, and receipt I/O failure stays fenced until a fault-free retry. RED on 52b283c8a with final tests overlaid: 79 existing tests pass and all 16 new regressions fail as expected across request-bootstrap, project-bootstrap and working (95 total, zero skips); all five supplied reproducers were ported with corrected assertions. Final gates, all zero failures/skips/cancellations: focused 103/103 (baseline 90), bus 1499/1499 (1488), business 307/307 (305), project+seat 274/274 (271), launcher+stack-release including external-release cases 286/286 (286). Gate 5 uses node --test packages/cli/tests/launcher.test.mjs packages/cli/tests/stack-release*.test.mjs because these are CLI test files, not separate packages. Corrections: an initial gate-5 command named nonexistent package directories and ran no tests; an intermediate bus run failed the newly added per-child C1 regression, also reproduced on the base and fixed before the final green rerun. An earlier candidate gate-5 run was superseded and only its own descendant processes stopped; the final run completed. No preexisting test failure was found. Evidence: /mnt/storage/scratch/tmp/qRkZh1/ (before-1..4.log, before-5-cli.log, after-1..5.log, red-final-snapshot.log, verified-files.sha256). Provider interruption recovered from complete logs and matching source/test hashes. The queue refuses linked worktrees, so the explicit controller assignment was followed without changing the canonical queue. One local commit only; no push, live-instance action, role/contract/migration edit or residual C1/C2/C3 finding.
2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access. 2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access.
2026-11-04T04:05Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers, on 6dd4f92b0. The owner now picks the mode (new autonomous, new guided, existing project), answers the PM's questions and confirms an attachment with buttons; no copy-to-terminal step. BUS: 3p->3q append-only migration widens the request_events kind CHECK (bootstrap.completed, question.asked, question.answered, attach.selected, mode.changed, cancelled) and keeps rows, seq and guards; reduce gains question, selection-attach, mode and cancel state; PM-only request.question; owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console (the cancel verb is beyond the brief's list, needed for its Cancel rule) join request.get.console and request.projects.console in CONSOLE_VERBS (8 verbs, #human CLI guard untouched, agent intake verbs keep their names, ROUTED and roles/*.json untouched). Every write replays a same-op same-digest call as the current state, a different digest is request-operation-conflict, then revision, phase and question checks. A guided answer is an attributed human.input linked to question and request, once per op, secret-scanned, never a PRD approval and never a rewrite of the spec; the PM continuation is enqueued after commit. Attach records the pick from the authorized picker and the PM proposes exactly that project as a confirmation decision (a propose that differs is request-attachment-mismatch); nothing is mapped on the pick. A mode change takes the expected revision and mode_revision, bumps mode_revision and leaves the bound plan as evidence, so a pending autonomous approval or dispatch admission bound to the old mode_revision no longer matches at the next action. Cancel withdraws an open selection decision (pre-task decisions are blocking:false) or appends cancellation and keeps a created project and its mapping as evidence. thread.messages adds the request id to human instruction rows for the owner only. WEBUI: src/request-http.mjs routes GET /api/requests/:request, /projects, /draft and POST /answer|/attach|/mode|/cancel through consoleCall as the signed-in (provider, iss, sub); writes need the signed-in owner, a single same-origin Origin, Sec-Fetch-Site same-origin, application/json, an exact key set, a bounded body (4 KB, answers 64 KB / 8000 characters) and a live() recheck before the spawn; the draft read answers draft-unavailable (503) until Task 4 adds request.draft.console. serve.mjs and cli.mjs take requestRead/requestWrite (sso and send required, refused on the tester listener) and /api/config reports requests and requestWrites to the owner only. Page: public/requests.js mounts under the owner's latest three chat messages that opened a request (chat.js emits the mounts and calls MosaicRequests.mount after each paint), renders every bus string escaped with control and bidi characters shown as \uXXXX, answers the mode decision through the existing /api/decisions/:id/confirmation and /resolve pair, stores an answer in sessionStorage before sending it, and after a lost response reads the request first and resends the same op only on the owner's click. Nothing is retried by the page. Sign-out clears the panels and stored answers. RED->GREEN: packages/bus/tests/request-console.test.mjs (15), packages/webui/tests/request-http.test.mjs (14), packages/webui/tests/request-browser.test.mjs (12, real Chromium; browser.test.mjs passes 1/1 so the host browser limit did not apply). Existing pins updated as required consequences: 3q schema pin in 13 bus test files, console-decide N4 verb list (2 -> 8), browser.test.mjs script list (+/requests.js). GREEN: webui npm test 538/538, bus npm test 1492/1492 (the first full run had one failure, model-final-completion 656 FIFO startNice 16 !== 19, the same niceness flake recorded for Task 2; the file passed 21/21 alone and the full rerun was green), request-intake plus request-bootstrap 70/70, zero skips. Limits: until Task 6 the PM continuation after an answer is a plain INFO message; the draft PRD view has no panel until Task 4; the owner's attach pick is lost if the PM later appends a different setup wait; mode change is refused during bootstrap; a cancel queues behind an in-flight bootstrap; an open non-selection decision on a cancelled request is not withdrawn; a session being cleared or retired makes the answer fail closed; request_project_events kinds remain unwritten; 3q may collide with the parallel #802 migration number. Logs /mnt/storage/scratch/tmp/0pihwW/. Not executed on any running instance or Console; local commit only, no push/merge/PR/deployment.
+1
View File
@@ -2004,3 +2004,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/harden-project-bootstrap-executor | #802 bootstrap executor hardening | N1 working path confined before and inside the helper, I1 guarded() makes the freshness check and every protected write one synchronous run under the organization lock, I2 complete marker published before activation and readiness needs marker plus committed receipt at boot, reload and admission. Brief trio 90/90, bus 1488/1488, business 305/305, project+seat 271/271, launcher+stack-release 261/261, zero skips; no schema change; local commit only, no push. 2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/harden-project-bootstrap-executor | #802 bootstrap executor hardening | N1 working path confined before and inside the helper, I1 guarded() makes the freshness check and every protected write one synchronous run under the organization lock, I2 complete marker published before activation and readiness needs marker plus committed receipt at boot, reload and admission. Brief trio 90/90, bus 1488/1488, business 305/305, project+seat 271/271, launcher+stack-release 261/261, zero skips; no schema change; local commit only, no push.
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 | C1 permanent foreign-repo refusal, exclusive ownership claim and per-child internal-link/alternates checks; C2 async-held organization lock through records init, intent commit and rollback; C3 committed activate receipt before broker reload, fenced receipt failure and successful retry. All 16 added regressions fail on 52b283c8a; final focused 103/103, bus 1499/1499, business 307/307, project+seat 274/274, launcher+release including external cases 286/286, zero skips. Logs /mnt/storage/scratch/tmp/qRkZh1/; one local commit, no push, no role/contract/migration/live changes. 2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 | C1 permanent foreign-repo refusal, exclusive ownership claim and per-child internal-link/alternates checks; C2 async-held organization lock through records init, intent commit and rollback; C3 committed activate receipt before broker reload, fenced receipt failure and successful retry. All 16 added regressions fail on 52b283c8a; final focused 103/103, bus 1499/1499, business 307/307, project+seat 274/274, launcher+release including external cases 286/286, zero skips. Logs /mnt/storage/scratch/tmp/qRkZh1/; one local commit, no push, no role/contract/migration/live changes.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push.
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
+1 -1
View File
@@ -535,7 +535,7 @@ CREATE TABLE request_events (
business TEXT NOT NULL, business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id), request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1), revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled')), kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled')),
actor_role TEXT, actor_run TEXT, actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL, at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'), body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
+73 -10
View File
@@ -160,7 +160,16 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']); 'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb // Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap. // refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console']); export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
// What an owner's console cap may ask of the request lifecycle (a read, the intake controls, and Task 4's mode and draft).
const INTAKE_CONSOLE = Object.freeze(['get', 'answer', 'attach', 'cancel', 'mode', 'draft']);
const REQUEST_VERBS = Object.freeze({ 'request.open': 'open', 'request.get': 'get', 'request.projects': 'projects', 'request.propose': 'propose',
'prd.submit': 'submit', 'request.question': 'question', 'request.get.console': 'get', 'request.projects.console': 'projects',
'request.answer.console': 'answer', 'request.attach.console': 'attach', 'request.cancel.console': 'cancel' });
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They // Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a // read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each. // collaborator grant on the project and instance, so a viewer gets access-refused on each.
@@ -614,8 +623,20 @@ export class Broker {
if (identity.human) this.#event(identity, 'action.allowed', { operation, request: state.request }, state.request); if (identity.human) this.#event(identity, 'action.allowed', { operation, request: state.request }, state.request);
else this.#consumeAuthority(identity, 'message.send', { target: state.pm }, { operation, request: state.request }); else this.#consumeAuthority(identity, 'message.send', { target: state.pm }, { operation, request: state.request });
}, },
// Task 3 (#1839): the owner's Console answer, pick and cancel, each one attributed human.input and one INFO message to the PM. They
// run inside the lifecycle's journal transaction, so the evidence and the journal row commit together or not at all.
recordAnswer: (identity, state, a) => this.#intakeInput(identity, state,
{ kind: 'answer', request: state.request, question: a.question, op: a.op, sha256: sha256(a.text) },
`Answer to question ${a.question} on request ${state.request}:\n${a.text}`),
recordAttach: (identity, state, a) => this.#intakeInput(identity, state,
{ kind: 'attach', request: state.request, project: a.project, op: a.op },
`The owner picked existing project ${a.project} for request ${state.request}. Propose exactly this project for the owner to confirm.`),
recordCancel: (identity, state, a) => this.#intakeInput(identity, state,
{ kind: 'cancel', request: state.request, decision: a.decision, op: a.op },
`The owner cancelled request ${state.request}. A project it already created stays as evidence.`),
withdrawDecision: (identity, state, decision) => this.#withdrawIntake(identity, state, decision),
}); });
if (!this.#requests || !['open','get','projects','propose','resume','recover'].every(k => typeof this.#requests[k] === 'function')) if (!this.#requests || !['open','get','projects','propose','question','answer','attach','cancel','modeConsole','draftConsole','resume','recover'].every(k => typeof this.#requests[k] === 'function'))
throw new TypeError('invalid request lifecycle'); throw new TypeError('invalid request lifecycle');
} }
} }
@@ -623,14 +644,22 @@ export class Broker {
if (!s || s.business !== state.business) fail('request-access-refused'); if (!s || s.business !== state.business) fail('request-access-refused');
if (s.reader) fail('read-only'); if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused'); if (s.tester) fail('tester-refused');
if (s.user || (s.console && !['mode','draft'].includes(operation))) fail('human-required'); if (s.user) fail('human-required');
if (['mode','draft'].includes(operation) && !s.console) fail('human-required'); // Task 3 (#1839): only the configured owner's Console cap reaches the owner's controls, and they reach nothing else.
if (s.human) { // The CLI human guard in #human is not weakened: a console cap never passes it. mode and draft are Task 4's.
if (!s.console) this.#human(s); if (s.console) {
if (!INTAKE_CONSOLE.includes(operation)) fail('human-required');
if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused'); if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused');
if (['propose','prd.submit'].includes(operation)) fail('agent-required'); return;
}
if (['mode', 'draft'].includes(operation)) fail('human-required');
if (s.human) {
this.#human(s);
if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused');
if (!['open', 'get'].includes(operation)) fail('agent-required');
} else { } else {
this.#agent(s); this.#agent(s);
if (INTAKE_CONSOLE.includes(operation) && operation !== 'get') fail('human-required');
if (s.role !== state.pm || this.#business(s.business).roles[s.role]?.definition !== 'pm') fail('request-access-refused'); if (s.role !== state.pm || this.#business(s.business).roles[s.role]?.definition !== 'pm') fail('request-access-refused');
// No separate authority: intake preparation consumes the PM's existing message.send grant. // No separate authority: intake preparation consumes the PM's existing message.send grant.
if (operation === 'prd.submit') this.#checkAuthority(s, 'record.write', { target: state.project }); if (operation === 'prd.submit') this.#checkAuthority(s, 'record.write', { target: state.project });
@@ -685,6 +714,30 @@ export class Broker {
return result; return result;
} catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); } } catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); }
} }
// Task 3 (#1839): one owner control as evidence. The message tells the PM; the human.input event names the owner's verified
// Console subject and takes the message id as its subject, so the PM's message.receive row carries it as the request.
// A message into a session being cleared or retired is cancelled at arrival, and a control that cannot reach the PM
// fails closed here, which rolls back the journal row that called this.
#intakeInput(s, state, body, text) {
if (!s.console || s.human !== state.owner || s.human !== this.#business(s.business).human) fail('human-required');
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
mid, this.#now(), s.business, 'human', 'human-console', state.pm, 'INFO', text);
const cancelled = this.#route(s.business, mid, state.pm, state.session);
if (cancelled) fail(cancelled);
return this.#event(s, 'human.input', { ...body, via: 'console', subject: s.console, message: mid }, mid);
}
// The cancel of a request whose selection decision is still open closes that decision as withdrawn by the owner.
#withdrawIntake(s, state, decision) {
if (!s.console || s.human !== state.owner) fail('human-required');
const d = this.#decision(s, decision);
if (d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.raised_by_role !== state.pm) fail('request-selection-refused');
if (this.#closed(d.id)) fail('decision-closed');
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id, this.#now(), 'withdrawn', s.human, null, null, 'console').lastInsertRowid);
this.#event(s, 'human.input', { kind: 'answer', decision: d.id, op: 'withdrawn', choice: null, via: 'console', subject: s.console, decisionSeq: seq }, d.id);
}
#requestOrigin(s, request) { #requestOrigin(s, request) {
const b = this.#business(s.business); const b = this.#business(s.business);
const e = this.#store.get("SELECT * FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL", s.business, request); const e = this.#store.get("SELECT * FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL", s.business, request);
@@ -5137,11 +5190,11 @@ export class Broker {
? this.#requests.catalog.revoke(s, structuredClone(args)) : this.#requests.modeConsole(s, structuredClone(args))) ? this.#requests.catalog.revoke(s, structuredClone(args)) : this.#requests.modeConsole(s, structuredClone(args)))
.catch(e => { throw this.#refused(s, new BusError(e.code ?? 'request-failed'), request); }); .catch(e => { throw this.#refused(s, new BusError(e.code ?? 'request-failed'), request); });
} }
if (['request.open','request.get','request.projects','request.propose','prd.submit'].includes(request.verb)) { if (Object.hasOwn(REQUEST_VERBS, request.verb)) {
if (s.reader) fail('read-only'); if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused'); if (s.tester) fail('tester-refused');
if (!this.#requests) fail('request-not-configured'); if (!this.#requests) fail('request-not-configured');
const method = request.verb === 'prd.submit' ? 'submit' : request.verb.slice('request.'.length); const method = REQUEST_VERBS[request.verb];
return Promise.resolve().then(() => this.#requests[method](s, structuredClone(args))) return Promise.resolve().then(() => this.#requests[method](s, structuredClone(args)))
.then(result => { this.#secretCheck(result); return result; }) .then(result => { this.#secretCheck(result); return result; })
.catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); }); .catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
@@ -7287,7 +7340,17 @@ export class Broker {
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`, `SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session, session,
after, after,
).map((m) => this.#withCheck(m)); ).map((m) => {
const row = this.#withCheck(m);
// Task 3 (#1839): the owner's own chat names the request an instruction opened, so the Console mounts its intake
// panel there. Only the owner's CLI cap sees it: a tester or granted user row keeps its old shape, and the owner's
// later answer, pick, mode and cancel messages are not instructions.
if (!s.human) return row;
const request = this.#store.get(
"SELECT id FROM events WHERE business=? AND kind='human.input' AND subject=? AND json_extract(body,'$.kind')='instruction' ORDER BY seq LIMIT 1",
s.business, m.id)?.id;
return request ? { ...row, request } : row;
});
} }
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share. // Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant // A user's collaborator grant must still cover the thread, and a user target needs a live grant
+53
View File
@@ -0,0 +1,53 @@
-- #797 / #1839 Task 3: the request journal learns three more kinds of event for the Console intake controls:
-- question.asked (the project manager's one open question), question.answered (the owner's answer, linked to a
-- human.input event) and attach.selected (the owner's pick of an existing project, before the PM proposes it).
-- 3p-3q (lifecycle Task 4 core) shipped prd.submitted, prd.activated, prd.invalidated and mode.changed, and this step
-- keeps every one of them: the new list is that list plus these three, so a 3q journal replays into 3r unchanged.
-- request_events keeps its rows, its seq values and its guards; only the kind list widens. The guards that read it,
-- request_events_origin, request_events_revision and request_effects_binding, are dropped first so the rename
-- rewrites nothing, then created again as schema.sql has them. The copy runs with the guards gone, in seq order,
-- inside the migration's one transaction.
DROP TRIGGER request_events_origin;
DROP TRIGGER request_events_revision;
DROP TRIGGER request_effects_binding;
DROP TRIGGER request_events_no_update;
DROP TRIGGER request_events_no_delete;
DROP TRIGGER request_events_no_replace;
DROP INDEX request_events_request;
ALTER TABLE request_events RENAME TO request_events_3q;
CREATE TABLE request_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
request TEXT NOT NULL REFERENCES events(id),
revision INTEGER NOT NULL CHECK (revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled')),
actor_role TEXT, actor_run TEXT,
at TEXT NOT NULL,
body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body) = 'object'),
CHECK ((actor_role IS NULL) = (actor_run IS NULL)),
UNIQUE (business,request,revision)
) STRICT;
INSERT INTO request_events(seq,business,request,revision,kind,actor_role,actor_run,at,body)
SELECT seq,business,request,revision,kind,actor_role,actor_run,at,body FROM request_events_3q ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='request_events';
INSERT INTO sqlite_sequence(name,seq) SELECT 'request_events', seq FROM sqlite_sequence WHERE name='request_events_3q';
DROP TABLE request_events_3q;
CREATE INDEX request_events_request ON request_events (business,request,seq);
CREATE TRIGGER request_events_origin BEFORE INSERT ON request_events
WHEN NOT EXISTS (SELECT 1 FROM events e JOIN messages m ON m.id=e.subject AND m.id=json_extract(e.body,'$.message')
WHERE e.id=NEW.request AND e.business=NEW.business AND m.business=e.business AND e.kind='human.input'
AND e.actor_role IS NULL AND e.actor_run IS NULL AND json_extract(e.body,'$.kind')='instruction' AND m.from_role='human')
BEGIN SELECT RAISE(ABORT, 'request-origin-refused'); END;
CREATE TRIGGER request_events_revision BEFORE INSERT ON request_events
WHEN NEW.revision <> 1 + coalesce((SELECT max(revision) FROM request_events WHERE business=NEW.business AND request=NEW.request),0)
OR (NEW.revision=1) <> (NEW.kind='opened')
BEGIN SELECT RAISE(ABORT, 'request-revision-conflict'); END;
CREATE TRIGGER request_effects_binding BEFORE INSERT ON request_effects
WHEN NOT EXISTS (SELECT 1 FROM request_events WHERE business=NEW.business AND request=NEW.request)
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND (digest<>NEW.digest OR kind<>NEW.kind))
OR (NEW.stage<>'prepared' AND NOT EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage='prepared'))
OR EXISTS (SELECT 1 FROM request_effects WHERE business=NEW.business AND request=NEW.request AND op=NEW.op AND stage IN ('committed','refused'))
BEGIN SELECT RAISE(ABORT, 'request-effect-conflict'); END;
CREATE TRIGGER request_events_no_update BEFORE UPDATE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_delete BEFORE DELETE ON request_events BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
CREATE TRIGGER request_events_no_replace BEFORE INSERT ON request_events WHEN EXISTS (SELECT 1 FROM request_events WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_events is append-only'); END;
+94 -3
View File
@@ -1,8 +1,10 @@
// Host controller for pre-task intake. Task 1 prepares and records selections only. // Host controller for pre-task intake. Task 1 prepares and records selections only. Task 3 (#1839) adds the
// PM's question and the owner's Console answer, existing-project pick and cancellation. The owner's mode change is Task 4's
// modeConsole, which moves the project's mode revision and every request attached to it.
// Later executors must consume these exact immutable bindings, never approvalChoice. // Later executors must consume these exact immutable bindings, never approvalChoice.
import { lstatSync } from 'node:fs'; import { lstatSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { exact, object, id, key, text, digest, validatePlan, refuse } from './request-store.mjs'; import { exact, object, id, key, text, digest, validatePlan, refuse, positive, optionList, QUESTION_PHASES, PICKER_CODES } from './request-store.mjs';
export function createRequestLifecycle({ journal, services }) { export function createRequestLifecycle({ journal, services }) {
if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable'); if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable');
@@ -117,6 +119,8 @@ export function createRequestLifecycle({ journal, services }) {
if (!services.project(state.business, args.source_project)) refuse('request-project-refused'); if (!services.project(state.business, args.source_project)) refuse('request-project-refused');
// The route is journaled when the owner stages it; no setup wait code can erase or imply it. // The route is journaled when the owner stages it; no setup wait code can erase or imply it.
const existing = state.route === 'existing'; const existing = state.route === 'existing';
// The owner's pick binds the PM: it proposes the picked project or nothing (#1839).
if (existing && state.wait?.code === 'request-attachment-selected' && args.project !== state.wait.subject) refuse('request-attachment-mismatch');
if (!existing && services.project(state.business, args.project)) refuse('request-project-collision'); if (!existing && services.project(state.business, args.project)) refuse('request-project-collision');
let candidate = null; let candidate = null;
if (existing) { if (existing) {
@@ -302,7 +306,94 @@ export function createRequestLifecycle({ journal, services }) {
return runBootstrap(confirmed); return runBootstrap(confirmed);
}); });
} }
return Object.freeze({ open, get, projects, propose, submit, modeConsole, // The Task 3 verbs share one shape: validate, authorize, replay a recorded op, then check revision and phase in
// that order, so a lost response can be retried and a stale client always learns the revision moved.
const need = name => typeof services[name] === 'function' ? services[name] : refuse('request-services-unavailable');
const replay = (state, op, proposal_digest) => {
const old = journal.proposal(state.business, state.request, op);
if (old && old.proposal_digest !== proposal_digest) refuse('request-operation-conflict');
return old !== null;
};
const ended = state => { if (['cancelled', 'delivered'].includes(state.phase)) refuse('request-phase-refused'); };
// The PM's one outstanding question. It is a journal event, not a decision: an answer is text and approves nothing.
async function question(identity, args) {
exact(args, ['request','revision','op','text'], ['options']);
if (!id(args.request) || !positive(args.revision) || !id(args.op) || !text(args.text, 2000) || ('options' in args && !optionList(args.options))) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'question');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (!QUESTION_PHASES.includes(state.phase) || state.wait?.kind === 'decision') refuse('request-phase-refused');
if (state.question && state.question.answer === null) refuse('request-question-open');
return journal.transaction(tx => {
const result = append(state, 'question.asked', { op: args.op, proposal_digest, id: 'q-' + proposal_digest.slice(0, 16), text: args.text,
options: structuredClone(args.options ?? []) }, identity, tx.appendRequest);
services.record?.(identity, 'request.question', result); return result;
});
});
}
async function answer(identity, args) {
exact(args, ['request','revision','question','op','text']);
if (!id(args.request) || !positive(args.revision) || !id(args.question) || !id(args.op) || !text(args.text, 8000)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'answer');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
ended(state);
if (!state.question || state.question.answer !== null) refuse('request-question-closed');
if (state.question.id !== args.question) refuse('request-question-mismatch');
// The event, the message to the PM and the journal row commit together, or none does.
return journal.transaction(tx => {
const event = need('recordAnswer')(identity, structuredClone(state), { question: args.question, op: args.op, text: args.text });
return append(state, 'question.answered', { op: args.op, proposal_digest, question: args.question, answer: event }, identity, tx.appendRequest);
});
});
}
// The owner's pick of one project the picker offers. The pick is evidence and a wait; the PM still proposes it and the
// owner still confirms the decision that proposal raises, so nothing is attached here.
async function attach(identity, args) {
exact(args, ['request','revision','project','op']);
if (!id(args.request) || !positive(args.revision) || !key(args.project) || !id(args.op)) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'attach');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'selection' || state.route !== 'existing' || state.wait?.kind !== 'setup' || !PICKER_CODES.includes(state.wait.code)) refuse('request-phase-refused');
// The inventory is asynchronous; the transaction below rereads the journal and the check is repeated there.
if (!(await projects(identity, { request: args.request })).some(p => p.project === args.project)) refuse('request-project-refused');
return journal.transaction(tx => {
const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found');
authorize(identity, current, 'attach');
if (current.revision !== args.revision) refuse('request-revision-conflict');
need('recordAttach')(identity, structuredClone(current), { project: args.project, op: args.op });
return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project }, identity, tx.appendRequest);
});
});
}
// Withdraws the open selection decision, if any, and appends the cancellation. A project, mapping or receipt that
// already exists stays as evidence; nothing is deleted. A bootstrap in flight holds this request's queue, so a
// cancel waits for it to finish instead of racing it.
async function cancel(identity, args) {
exact(args, ['request','revision','op']);
if (!id(args.request) || !positive(args.revision) || !id(args.op)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'cancel');
const proposal_digest = digest(args);
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
ended(state);
const decision = state.wait?.kind === 'decision' ? state.selection?.decision ?? refuse('invalid-request-event') : null;
return journal.transaction(tx => {
if (decision !== null) need('withdrawDecision')(identity, structuredClone(state), decision);
need('recordCancel')(identity, structuredClone(state), { op: args.op, decision });
return append(state, 'cancelled', { decision, op: args.op, proposal_digest }, identity, tx.appendRequest);
});
});
}
return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, modeConsole,
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');
+40 -3
View File
@@ -14,7 +14,7 @@ export const id = x => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
export const key = x => typeof x === 'string' && ID_PATTERN.test(x); export const key = x => typeof x === 'string' && ID_PATTERN.test(x);
const hash = x => typeof x === 'string' && /^[a-f0-9]{64}$/.test(x); const hash = x => typeof x === 'string' && /^[a-f0-9]{64}$/.test(x);
const oid = x => typeof x === 'string' && /^[a-f0-9]{40}([a-f0-9]{24})?$/.test(x); const oid = x => typeof x === 'string' && /^[a-f0-9]{40}([a-f0-9]{24})?$/.test(x);
const positive = x => Number.isSafeInteger(x) && x > 0; export const positive = x => Number.isSafeInteger(x) && x > 0;
export function exact(x, keys, optional = [], code = 'invalid-request') { export function exact(x, keys, optional = [], code = 'invalid-request') {
if (!object(x) || keys.some(k => !Object.hasOwn(x, k)) || Object.keys(x).some(k => !keys.includes(k) && !optional.includes(k))) refuse(code); if (!object(x) || keys.some(k => !Object.hasOwn(x, k)) || Object.keys(x).some(k => !keys.includes(k) && !optional.includes(k))) refuse(code);
} }
@@ -23,6 +23,11 @@ const canonical = x => Array.isArray(x) ? x.map(canonical) : object(x)
export const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(canonical(x))).digest('hex'); export const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(canonical(x))).digest('hex');
const equal = (a, b) => digest(a) === digest(b); const equal = (a, b) => digest(a) === digest(b);
const nullableId = x => x === null || id(x); const nullableId = x => x === null || id(x);
// A question belongs to the PM's discovery, PRD review and planning work.
export const QUESTION_PHASES = ['discovery','prd-review','planning'];
export const PICKER_CODES = ['request-existing-project','request-attachment-selected'];
export const optionList = x => Array.isArray(x) && x.length <= 8 && x.every(o => object(o) && Object.keys(o).length === 2 && id(o.key) && text(o.text, 200)) &&
new Set(x.map(o => o.key)).size === x.length;
export function validatePlan(p) { export function validatePlan(p) {
const code = 'invalid-request-plan', shape = (x, keys) => exact(x, keys, [], code); const code = 'invalid-request-plan', shape = (x, keys) => exact(x, keys, [], code);
shape(p, ['version','business','request','project','name','chat_project','mode','organization_revision','source','code_root','records_root','tracker','staff','intent','project_vars','existing']); shape(p, ['version','business','request','project','name','chat_project','mode','organization_revision','source','code_root','records_root','tracker','staff','intent','project_vars','existing']);
@@ -122,6 +127,27 @@ function reduce(state, e) {
if (s.phase !== 'bootstrap' || s.plan_digest !== b.digest || b.project !== s.project || !id(b.chat_project) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code); if (s.phase !== 'bootstrap' || s.plan_digest !== b.digest || b.project !== s.project || !id(b.chat_project) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code);
s.chat_project = b.chat_project; s.spec_commit = b.spec_commit; s.phase = 'discovery'; s.chat_project = b.chat_project; s.spec_commit = b.spec_commit; s.phase = 'discovery';
s.wait = { kind: 'setup', code: 'request-discovery-unavailable', subject: s.request, resume_phase: 'discovery', observed_revision: s.revision }; s.wait = { kind: 'setup', code: 'request-discovery-unavailable', subject: s.request, resume_phase: 'discovery', observed_revision: s.revision };
} else if (e.kind === 'question.asked') {
// The PM's one outstanding question. The id is part of the body so a reader never recomputes it.
exact(b, ['op','proposal_digest','id','text','options'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !id(b.id) || !text(b.text, 2000) || !optionList(b.options)) refuse(code);
if (!QUESTION_PHASES.includes(s.phase) || s.wait?.kind === 'decision' || (s.question && s.question.answer === null)) refuse(code);
s.question = { id: b.id, text: b.text, options: structuredClone(b.options), answer: null };
s.wait = { kind: 'question', code: 'question', subject: b.id, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'question.answered') {
// `answer` is the owner's human.input event. appendInTransaction checks that event; text never approves anything.
exact(b, ['op','proposal_digest','question','answer'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !id(b.question) || !id(b.answer)) refuse(code);
if (!s.question || s.question.answer !== null || s.question.id !== b.question) refuse(code);
s.question.answer = b.answer;
if (s.wait?.kind === 'question' && s.wait.subject === b.question) s.wait = null;
} else if (e.kind === 'attach.selected') {
// The owner's pick of an existing project. It is a pick only: no mapping, effect or decision exists until the
// PM proposes exactly this project and the owner confirms the decision that proposal raises.
exact(b, ['op','proposal_digest','project'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !key(b.project)) refuse(code);
if (s.phase !== 'selection' || s.route !== 'existing' || s.wait?.kind !== 'setup' || !PICKER_CODES.includes(s.wait.code)) refuse(code);
s.wait = { kind: 'setup', code: 'request-attachment-selected', subject: b.project, resume_phase: 'selection', observed_revision: s.revision };
} else if (e.kind === 'prd.submitted') { } else if (e.kind === 'prd.submitted') {
exact(b, ['candidate','decision','op','proposal_digest','authors','route'], [], code); exact(b, ['candidate','decision','op','proposal_digest','authors','route'], [], code);
validateCandidate(b.candidate); validateCandidate(b.candidate);
@@ -149,8 +175,12 @@ function reduce(state, e) {
s.candidate = null; s.snapshot = null; s.phase = 'discovery'; s.candidate = null; s.snapshot = null; s.phase = 'discovery';
s.wait = { kind: 'setup', code: 'request-mode-changed', subject: s.request, resume_phase: s.phase, observed_revision: s.revision }; s.wait = { kind: 'setup', code: 'request-mode-changed', subject: s.request, resume_phase: s.phase, observed_revision: s.revision };
} else if (e.kind === 'cancelled') { } else if (e.kind === 'cancelled') {
exact(b, ['decision'], [], code); // `decision` is the open selection decision the cancellation closes, or null when none is open. A project or
if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision) refuse(code); // mapping that already exists stays where it is; the journal never undoes an effect.
exact(b, ['decision'], ['op','proposal_digest'], code);
if (('op' in b) !== ('proposal_digest' in b) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code);
if (s.phase === 'delivered') refuse(code);
if (s.wait?.kind === 'decision' ? s.selection?.decision !== b.decision || s.wait.subject !== b.decision : b.decision !== null) refuse(code);
s.phase = 'cancelled'; s.wait = null; s.selection = null; s.phase = 'cancelled'; s.wait = null; s.selection = null;
} else refuse(code); } else refuse(code);
return s; return s;
@@ -209,6 +239,13 @@ export function createRequestStore(store) {
if (e.kind === 'selection.confirmed' && !store.get('SELECT 1 FROM request_project_events WHERE business=? AND project=?', e.business, next.project)) if (e.kind === 'selection.confirmed' && !store.get('SELECT 1 FROM request_project_events WHERE business=? AND project=?', e.business, next.project))
store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,1,'attached',?)", store.run("INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,?,?,1,'attached',?)",
new Date().toISOString(), e.business, next.project, JSON.stringify({ mode: next.mode, request: e.request })); new Date().toISOString(), e.business, next.project, JSON.stringify({ mode: next.mode, request: e.request }));
// An answer follows only from the owner's own console human.input for that question and op, so the journal
// cannot record text the owner never sent or the origin instruction as an answer.
if (e.kind === 'question.answered') {
const a = store.get("SELECT body FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL", e.business, e.body.answer);
const x = a && JSON.parse(a.body);
if (!x || x.kind !== 'answer' || x.via !== 'console' || x.request !== e.request || x.question !== e.body.question || x.op !== e.body.op) refuse('invalid-request-event');
}
// A bootstrap outcome follows only from the receipts it names, so the journal cannot claim a project // A bootstrap outcome follows only from the receipts it names, so the journal cannot claim a project
// that was not activated, or retreat from one whose mapping already landed. // that was not activated, or retreat from one whose mapping already landed.
if (e.kind === 'bootstrap.completed' || e.kind === 'bootstrap.stale') { if (e.kind === 'bootstrap.completed' || e.kind === 'bootstrap.stale') {
+9 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '2a6a161b3d671418e690942f02203b4a014e939b46ae8f651da11f091dac5455'; export const SCHEMA_SHA256 = '4dfc55b42a5967fb0ef2067040bd187db870596e2edda64e51f1a1025ada2092';
export const SCHEMA_VERSION = '3q'; export const SCHEMA_VERSION = '3r';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. // Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8'); const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -96,6 +96,13 @@ const migrations = [{
}, { }, {
from: '3p', to: '3q', from: '3p', to: '3q',
sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'), sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'),
}, {
// #797 / #1839 Task 3: the request journal's question.asked, question.answered and attach.selected kinds, for the
// Console intake controls, added to the kind list 3p-3q left (which already holds the PRD kinds and mode.changed).
// request_events is rebuilt with the union and keeps every row, seq and guard.
from: '3q',
to: '3r',
sql: readFileSync(new URL('./migrations/3q-3r.sql', import.meta.url), 'utf8'),
}]; }];
const timedTables = [ const timedTables = [
'events', 'events',
@@ -91,6 +91,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql')); copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql')); copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
@@ -98,7 +99,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path); const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50. // Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]); assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -148,7 +149,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows); assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it. // Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
}); });
@@ -160,14 +161,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h, schema: ddl3h,
store: (source) => source store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3h';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8'); const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/); assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows); assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
}); });
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8'); let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3c';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source); assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step'); assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run'); assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null }); const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source); const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value; const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex'); const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8'); let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`) source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3r';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3s';")
.replace('}];\nconst timedTables', `}, { from: '3q', to: '3r', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); .replace('}];\nconst timedTables', `}, { from: '3r', to: '3s', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql']) for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name)); copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs'))); const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true }); const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r'); assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture'); assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0); assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved'); assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname; const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3q ready/); assert.match(migrated.stdout, /bus schema 3r ready/);
new Store(root).close(); new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2); assert.equal(usage.status, 2);
+5 -1
View File
@@ -86,8 +86,12 @@ function setup(t) {
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant }; return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
} }
// #797 / #1839 Task 3 appends the five request-intake console verbs to Task 4's five and only those: the decision
// pair stays first and unchanged, and no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => { test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console']); assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false); assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS)); assert.ok(Object.isFrozen(CONSOLE_VERBS));
}); });
+5 -4
View File
@@ -81,6 +81,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql')); copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql')); copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { function fresh(t) {
@@ -102,7 +103,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path); const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path); const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -128,7 +129,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
}); });
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -139,14 +140,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j, schema: ddl3j,
store: (source) => source store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3j';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/); assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+5 -4
View File
@@ -84,6 +84,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql')); copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql'));
copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql')); copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql'));
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { function fresh(t) {
@@ -107,7 +108,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path); const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name)); const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -132,7 +133,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
}); });
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -143,14 +144,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i, schema: ddl3i,
store: (source) => source store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3i';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8'); const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/); assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0); assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) }); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+3 -3
View File
@@ -168,12 +168,12 @@ 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('3p to 3q 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');
const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(",'prd.submitted','prd.activated','prd.invalidated','mode.changed'", ''); .replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed'", '');
const db = new DatabaseSync(path); db.exec(ddl); const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z'; const at = '2026-11-03T00:00:00.000Z';
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3p'); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3p');
@@ -188,7 +188,7 @@ test('3p to 3q migration preserves existing request rows and the append-only gua
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before); assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at); store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/); assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/); assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+310
View File
@@ -0,0 +1,310 @@
// #797 / #1839 Task 3: the owner's Console controls over the request journal, and the PM's question.
// The webui side is packages/webui/tests/request-http.test.mjs; this file drives the broker verbs
// those routes call, with a real capability for every caller.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Store, SCHEMA_VERSION, schemaDigest } from '../src/store.mjs';
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
import { createRequestStore } from '../src/request-store.mjs';
import { createRequestLifecycle } from '../src/request-lifecycle.mjs';
const hash = x => createHash('sha256').update(x).digest('hex');
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' };
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'request-console-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm', 'chat'].map(key => [key, {
definition: key === 'otherpm' ? 'pm' : key === 'chat' ? 'assistant' : key, authority: { withinRole: ['message.send'], crossRole: [] },
}])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') },
library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } };
const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({
version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode,
organization_revision: 'org-1', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'),
records_root: existing ? business.projects[args.project].repo : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' },
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
project_vars: {}, existing,
}));
const host = {
listProjects: () => [
{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } },
{ business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'unknown', name: 'Unknown', tracker: null },
],
preparePlans: (state, args, source) => plans(state, args, source, false),
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
admitPlan: () => ({ ready: true }),
};
let lifecycle, journal;
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => {
journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...services } }); return lifecycle;
} });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
console: b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }) };
for (const role of ['pm', 'cto', 'coder', 'otherpm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
const call = async (who, verb, args = {}) => b.request(caps[who], { verb, args });
const source = b.request(caps.human, { verb: 'message.send', args: { to: 'pm', body: intent } });
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
const existing = (state, op) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2 });
return { root, b, store, call, source, propose, existing, business, get journal() { return journal; }, get lifecycle() { return lifecycle; } };
}
const n = (x, sql, ...p) => x.store.get(sql, ...p).n;
const get = (x, who = 'console') => x.call(who, 'request.get.console', { request: x.source.request });
async function atSelection(t) {
const x = setup(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
x.state = await x.call('pm', 'request.propose', x.propose(opened));
return x;
}
async function atPicker(t) {
const x = await atSelection(t);
await x.call('human', 'decision.resolve', { id: x.state.selection.decision, choice: 'existing' });
x.state = await x.lifecycle.resume('demo', x.source.request);
return x;
}
async function atDiscovery(t) {
const x = await atPicker(t);
const picked = await x.call('console', 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
await x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest });
x.state = await x.lifecycle.resume('demo', x.source.request);
assert.equal(x.state.phase, 'discovery');
return x;
}
const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request: state.request, revision: state.revision, op: 'q-1', text: 'Which platform?',
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list is Task 4 five plus the five intake verbs, in that order', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
const x = await atSelection(t), request = x.source.request;
const args = { 'request.get.console': { request }, 'request.projects.console': { request },
'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' },
'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' },
'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' },
'request.cancel.console': { request, revision: x.state.revision, op: 'o4' } };
for (const who of ['human', 'pm', 'cto', 'coder', 'otherpm', 'reader', 'tester'])
for (const [verb, a] of Object.entries(args)) await assert.rejects(async () => x.call(who, verb, a), /human-required|read-only|tester-refused/, `${who} ${verb}`);
for (const verb of ['request.get', 'request.projects', 'request.propose', 'request.question', 'request.open'])
await assert.rejects(async () => x.call('console', verb, { request }), /human-required/, verb);
assert.throws(() => x.b.bindConsole({ business: 'demo', human: 'someone', subject: SUBJECT }), /human-required/);
assert.equal(n(x, 'SELECT count(*) n FROM request_events'), 2, 'opened and selection.proposed only');
});
test('the console reads the same state and the same authorized project picker the PM does', async t => {
const x = await atSelection(t);
assert.deepEqual(await get(x), await x.call('pm', 'request.get', { request: x.source.request }));
assert.deepEqual((await x.call('console', 'request.projects.console', { request: x.source.request })).map(p => p.project), ['old']);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: x.source.request, extra: 1 }), /invalid-request/);
await assert.rejects(async () => x.call('console', 'request.get.console', { request: 'absent' }), /request-not-found/);
});
test('the PM asks one question at a time, only after discovery starts, and only the PM may ask', async t => {
const x = await atSelection(t);
await assert.rejects(async () => ask(x, x.state), /request-phase-refused/);
const d = await atDiscovery(t), state = d.state;
for (const who of ['cto', 'coder', 'otherpm', 'human', 'reader', 'tester']) await assert.rejects(async () => d.call(who, 'request.question', { request: state.request, revision: state.revision, op: 'q-x', text: 'No' }), /request-access-refused|agent-required|read-only|tester-refused/, who);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 0);
const asked = await ask(d, state);
assert.equal(asked.question.text, 'Which platform?'); assert.equal(asked.question.answer, null);
assert.deepEqual(asked.question.options, [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }]);
assert.match(asked.question.id, /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/);
assert.equal(asked.wait.kind, 'question'); assert.equal(asked.wait.subject, asked.question.id); assert.equal(asked.revision, state.revision + 1);
assert.deepEqual(await ask(d, state), asked, 'the same op returns the recorded question');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.asked'"), 1);
await assert.rejects(async () => ask(d, state, { text: 'Changed?' }), /request-operation-conflict/);
await assert.rejects(async () => ask(d, asked, { op: 'q-2' }), /request-question-open/);
await assert.rejects(async () => ask(d, state, { op: 'q-3' }), /request-revision-conflict/);
assert.deepEqual(await get(d), asked);
});
test('a guided answer is an attributed human.input linked to its question and request, recorded once per op', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state), request = d.source.request, text = 'Web, please.\nNo installs.';
const args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text };
const answered = await d.call('console', 'request.answer.console', args);
assert.equal(answered.question.answer !== null, true); assert.equal(answered.wait, null); assert.equal(answered.revision, asked.revision + 1);
const ev = d.store.get('SELECT * FROM events WHERE id=?', answered.question.answer);
assert.equal(ev.kind, 'human.input'); assert.equal(ev.actor_role, null); assert.equal(ev.actor_run, null);
const body = JSON.parse(ev.body);
assert.deepEqual(body, { kind: 'answer', request, question: asked.question.id, op: 'ans-1', via: 'console', subject: SUBJECT, message: ev.subject, sha256: hash(text) });
const m = d.store.get('SELECT * FROM messages WHERE id=?', ev.subject);
assert.equal(m.from_role, 'human'); assert.equal(m.from_run, 'human-console'); assert.equal(m.to_role, 'pm'); assert.equal(m.class, 'INFO');
assert.equal(m.body, `Answer to question ${asked.question.id} on request ${request}:\n${text}`);
const received = (await d.call('pm', 'message.receive')).find(r => r.id === m.id);
assert.equal(received.request, ev.id, 'the PM reaches the answer through the ordinary inbox');
assert.equal(d.store.get('SELECT body FROM messages WHERE id=?', d.source.id).body, intent, 'the original message bytes are untouched');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='instruction'"), 1);
// A replay of the same op is the recorded state: no second event, message or journal row.
assert.deepEqual(await d.call('console', 'request.answer.console', args), answered);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op')='ans-1'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1);
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 1);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, text: 'Other' }), /request-operation-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2' }), /request-revision-conflict/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, op: 'ans-2', revision: answered.revision }), /request-question-closed/);
assert.equal(n(d, "SELECT count(*) n FROM messages WHERE body LIKE 'Answer to question%'"), 1, 'refusals send nothing');
});
test('an answer needs the outstanding question, a stale revision refuses, and a text answer approves nothing', async t => {
const d = await atDiscovery(t), request = d.source.request;
const bare = { request, revision: d.state.revision, question: 'nope', op: 'ans-0', text: 'x' };
await assert.rejects(async () => d.call('console', 'request.answer.console', bare), /request-question-closed/, 'no question is open');
const asked = await ask(d, d.state), args = { request, revision: asked.revision, question: asked.question.id, op: 'ans-1', text: 'ok' };
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, question: 'other-question' }), /request-question-mismatch/);
await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, revision: asked.revision - 1 }), /request-revision-conflict/);
for (const bad of [{ text: '' }, { text: 'x'.repeat(8001) }, { text: 'a\0b' }, { text: 7 }, { note: 'x' }]) await assert.rejects(async () => d.call('console', 'request.answer.console', { ...args, ...bad }), /invalid-request/);
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.question') IS NOT NULL"), 0);
const answered = await d.call('console', 'request.answer.console', args);
// Text is not an approval: no decision, snapshot, task or selection changed.
assert.equal(answered.snapshot, null); assert.deepEqual(answered.tasks, []); assert.equal(answered.phase, 'discovery');
assert.equal(n(d, 'SELECT count(*) n FROM decisions'), 2, 'the mode decision and the confirmation, nothing more');
assert.equal(n(d, "SELECT count(*) n FROM events WHERE kind='task.created'"), 0);
});
test('attach records the owner pick from the authorized picker, then the PM proposes exactly that project for confirmation', async t => {
const x = await atPicker(t), request = x.source.request;
assert.equal(x.state.wait.code, 'request-existing-project');
const args = { request, revision: x.state.revision, project: 'old', op: 'attach-1' };
for (const project of ['library', 'unknown', 'factory', 'absent']) await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, project, op: 'attach-x' }), /request-project-refused/, project);
await assert.rejects(async () => x.call('console', 'request.attach.console', { ...args, revision: 1 }), /request-revision-conflict/);
const picked = await x.call('console', 'request.attach.console', args);
assert.equal(picked.phase, 'selection'); assert.equal(picked.project, null); assert.equal(picked.route, 'existing');
assert.equal(picked.wait.kind, 'setup'); assert.equal(picked.wait.code, 'request-attachment-selected'); assert.equal(picked.wait.subject, 'old');
assert.deepEqual(await x.call('console', 'request.attach.console', args), picked, 'the same op is the recorded pick');
const ev = x.store.get("SELECT * FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='attach'");
assert.deepEqual(JSON.parse(ev.body), { kind: 'attach', request, project: 'old', op: 'attach-1', via: 'console', subject: SUBJECT, message: ev.subject });
assert.equal(x.store.get('SELECT to_role,from_run FROM messages WHERE id=?', ev.subject).to_role, 'pm');
// Nothing is attached yet: the pick stages no mapping, no effect, no decision.
assert.equal(n(x, 'SELECT count(*) n FROM request_effects'), 0); assert.equal(n(x, 'SELECT count(*) n FROM decisions'), 1);
await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.existing(picked, 'existing-0'), project: 'library', source_project: 'library', name: 'Library' }), /request-attachment-mismatch|request-project-refused/);
const candidate = await x.call('pm', 'request.propose', x.existing(picked, 'existing-1'));
assert.equal(candidate.selection.bindings[0].plan.existing, true); assert.equal(candidate.project, null);
const d = await x.call('console', 'decision.preview.console', { id: candidate.selection.decision });
assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']);
assert.deepEqual(x.business.projects.old, { root: join(x.root, 'old'), repo: join(x.root, 'old-code') }, 'the mapping is not written');
});
test('attach is refused outside the existing-project picker and a pick can be changed before the PM proposes', async t => {
const sel = await atSelection(t);
await assert.rejects(async () => sel.call('console', 'request.attach.console', { request: sel.source.request, revision: sel.state.revision, project: 'old', op: 'a1' }), /request-phase-refused/);
const x = await atPicker(t), request = x.source.request;
const first = await x.call('console', 'request.attach.console', { request, revision: x.state.revision, project: 'old', op: 'a1' });
const again = await x.call('console', 'request.attach.console', { request, revision: first.revision, project: 'old', op: 'a2' });
assert.equal(again.wait.subject, 'old'); assert.equal(again.revision, first.revision + 1);
});
// The owner's mode change is Task 4's request.mode.console: it moves the project's mode revision and every attached request, and
// prd-activation.test.mjs drives it. Task 3 adds no second mode path, so the only mode assertions here are that every non-console
// caller is refused it (the second test above) and that the webui route and button reach it (the webui suites).
test('cancel withdraws an open selection decision and appends cancellation, keeping the source and every resource', async t => {
const x = await atSelection(t), request = x.source.request, decision = x.state.selection.decision;
const args = { request, revision: x.state.revision, op: 'cancel-1' };
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, revision: 1 }), /request-revision-conflict/);
const cancelled = await x.call('console', 'request.cancel.console', args);
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.wait, null); assert.equal(cancelled.selection, null); assert.equal(cancelled.project, null);
const close = x.store.get("SELECT * FROM decision_events WHERE decision=? AND op='withdrawn'", decision);
assert.equal(close.via, 'console'); assert.equal(close.by, 'jason');
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent);
assert.equal(n(x, 'SELECT count(*) n FROM request_effects'), 0);
assert.deepEqual(await x.call('console', 'request.cancel.console', args), cancelled, 'the same op is the recorded cancellation');
assert.deepEqual(await x.lifecycle.resume('demo', request), cancelled);
await assert.rejects(async () => x.call('console', 'request.cancel.console', { ...args, op: 'cancel-2', revision: cancelled.revision }), /request-phase-refused/);
await assert.rejects(async () => x.call('console', 'decision.preview.console', { id: decision }), /decision-closed/);
});
test('cancel after a project exists keeps the project and its mapping as evidence', async t => {
const x = await atDiscovery(t), before = structuredClone(x.business.projects);
const asked = await ask(x, x.state);
const cancelled = await x.call('console', 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-1' });
assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, 'old'); assert.equal(cancelled.mode, 'autonomous');
assert.deepEqual(x.business.projects, before);
const last = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='cancelled'").body);
assert.equal(last.decision, null); assert.equal(last.op, 'cancel-1');
});
test('the journal refuses a forged answer whose human.input is not the owner answer to that question', async t => {
const d = await atDiscovery(t), asked = await ask(d, d.state);
const forged = body => d.journal.appendRequest({ business: 'demo', request: d.source.request, expectedRevision: asked.revision, kind: 'question.answered',
actor: { role: null, run: null }, body: { op: 'f1', proposal_digest: 'a'.repeat(64), question: asked.question.id, ...body } });
assert.throws(() => forged({ answer: 'absent' }), /invalid-request-event/);
assert.throws(() => forged({ answer: d.source.request }), /invalid-request-event/, 'the origin instruction is not an answer');
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 0);
});
test('3q to 3r widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3r');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
const current = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
const db = new DatabaseSync(path); db.exec(ddl3q);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3q');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', AT);
db.prepare("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m1',?,'demo','human','human','pm','info','build a timer')").run(AT);
db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('r1',?,'demo','human.input','m1',?)").run(AT, JSON.stringify({ message: 'm1', kind: 'instruction' }));
const add = (revision, kind) => db.prepare("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')").run(revision, kind, AT);
add(1, 'opened'); add(2, 'setup.wait'); add(3, 'prd.submitted'); add(4, 'mode.changed');
assert.throws(() => add(5, 'question.asked'), /CHECK constraint failed/, 'the 3q schema refuses the new kinds');
const before = db.prepare('SELECT * FROM request_events ORDER BY seq').all();
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3r ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_digest'").value, schemaDigest(fresh), 'the replayed chain is the fresh schema');
fresh.close();
const next = (revision, kind) => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',?,?,?,'{}')", revision, kind, AT);
for (const [i, kind] of ['question.asked', 'question.answered', 'attach.selected'].entries()) next(5 + i, kind);
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 7, 'the sequence carries on from the old table');
next(8, 'prd.activated'); next(9, 'prd.invalidated');
assert.throws(() => next(9, 'cancelled'), /append-only/, 'a replayed revision is refused');
assert.throws(() => next(12, 'cancelled'), /request-revision-conflict/, 'the revision guard is back');
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','nope',1,'opened',?,'{}')", AT), /request-origin-refused/);
assert.throws(() => store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r1',10,'surprise',?,'{}')", AT), /CHECK constraint failed/);
assert.throws(() => store.run("UPDATE request_events SET kind='opened' WHERE seq=1"), /append-only/);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
store.close();
assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0, 'a second run is a no-op');
});
test('a thread message row carries its request id for the owner chat and for no one else', async t => {
const x = setup(t);
const project = (await x.call('human', 'project.create', { name: 'timer work', repo: 'factory' })).id;
const thread = (await x.call('human', 'thread.create', { project, name: 'timer', instance: 'chat' })).id;
const sent = await x.call('human', 'thread.send', { thread, body: intent });
assert.equal(typeof sent.request, 'string');
const rows = await x.call('human', 'thread.messages', { thread });
assert.equal(rows.length, 1); assert.equal(rows[0].id, sent.id); assert.equal(rows[0].request, sent.request, 'the owner reads the request id the panel mounts on');
const bare = x.store.get('SELECT * FROM messages WHERE id=?', sent.id);
assert.deepEqual({ ...rows[0], request: undefined }, { ...bare, request: undefined }, 'every other field is the stored row');
const tester = x.b.bindTester({ business: 'demo' }), tt = (await x.b.request(tester, { verb: 'thread.create', args: { project, name: 'tt', instance: 'chat' } })).id;
await x.b.request(tester, { verb: 'thread.send', args: { thread: tt, body: 'hello' } });
for (const row of await x.b.request(tester, { verb: 'thread.messages', args: { thread: tt } })) assert.equal('request' in row, false, 'a tester row keeps its old shape');
});
+5 -5
View File
@@ -273,8 +273,8 @@ test('plan_preparation_rejects_unknown_keys_changed_intent_tracker_modes_and_sym
test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_schema', t => { test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_schema', t => {
const root = mkdtempSync(join(tmpdir(), 'request-migration-')); const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
// Task 2's 3o→3p step widens only request_events.kind, so the 3o schema is the current one with the old list. // Tasks 2, 3 and 4 (3o→3p, 3p→3q, 3q→3r) widen only request_events.kind, so the 3n schema is the current one with the old list.
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled'", "'setup.wait','cancelled'"); const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8'); const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length); assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
const { schemaDigest, SCHEMA_VERSION } = awaitImportStore; const { schemaDigest, SCHEMA_VERSION } = awaitImportStore;
@@ -286,7 +286,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path); db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before); assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q ready\n'); assert.equal(SCHEMA_VERSION, '3q'); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3r ready\n'); assert.equal(SCHEMA_VERSION, '3r');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0); for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0); store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -307,7 +307,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'); const path = join(root, 'bus/bus.sqlite');
// The 3o schema is the current one with the narrower kind list. // The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','mode.changed','cancelled'", "'setup.wait','cancelled'"); const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z'; const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o); const db = new DatabaseSync(path); db.exec(ddl3o);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o'); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o');
@@ -323,7 +323,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600); db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q ready\n'); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3r ready\n');
const store = new Store(root); const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes'); assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects); assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+1 -1
View File
@@ -525,7 +525,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them. // Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them. // Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards. // #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q) widens request_events.kind in place; all 30 tables and 114 guards remain. // Task 4 (3p to 3q) and Task 3 (3q to 3r) widen request_events.kind in place; all 30 tables and 114 guards remain.
assert.equal(count('table') - 1, 30); assert.equal(count('table') - 1, 30);
assert.equal(count('trigger'), 114); assert.equal(count('trigger'), 114);
assert.equal(count('view'), 5); assert.equal(count('view'), 5);
+3 -3
View File
@@ -530,7 +530,7 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
const after = tables(path); const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events']) { for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events']) {
assert.deepEqual(after[name], [], name); assert.deepEqual(after[name], [], name);
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3m';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root); const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/); assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before); assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy. // The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t); const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
// The migrated schema is the fresh one, object for object. // The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-')); const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true })); t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3k';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root); const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/); assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy. // The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null }); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok'); assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []); assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3d';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step'); assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens']) for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread. // Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname; const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3q ready', ''].join('\n')); assert.equal(migrated.stdout, [...REPORT, 'bus schema 3r ready', ''].join('\n'));
}); });
+3 -3
View File
@@ -403,7 +403,7 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
const after = tables(path); const after = tables(path);
assert.deepEqual(after.thread_shares, []); assert.deepEqual(after.thread_shares, []);
delete after.thread_shares; delete after.thread_shares;
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3l';") .replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root); const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3q'); assert.equal(version(path), '3r');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before); assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy. // The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0); assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder'); assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+34
View File
@@ -360,6 +360,40 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
under `--sso` without `--send` it says the Console is read-only for decisions. under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Decisions routed to agent roles The tester listener has no decision routes. Decisions routed to agent roles
remain with those roles, using their real role/run attribution. remain with those roles, using their real role/run attribution.
- #797 / Gitea #1839 Task 3, the request intake panel (`src/public/requests.js`,
`src/request-http.mjs`): under each of the signed-in owner's latest three chat
messages that opened a request, the thread shows the request's phase, mode,
project and what it waits on, with buttons for what the owner decides. All of it
is escaped data (control and bidi characters show as `\uXXXX`). The panel reads
`GET /api/requests/<request>` (state) and `/projects` (the existing-project
picker, only the projects the bus authorizes for this request), re-reading on a
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`,
`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
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
Inbox (`/api/decisions/<id>/confirmation`, then `/resolve`). Picking an
existing project records the pick and the PM then stages a confirmation
decision; nothing is mapped until the owner confirms that. A text answer to the
PM's question is recorded as an attributed `human.input` linked to the question
and the request; it is never a PRD approval, and the original spec is not
rewritten. A mode change names the expected request and mode revisions and
withdraws an approval waiting in the old mode at the next action. Cancel asks
first, appends cancellation and keeps any project already created. An answer is
stored in `sessionStorage` before it is sent. After a lost response the page
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/<request>/draft` answers
`draft-unavailable` (503) until the bus has a `request.draft.console` verb
(lifecycle Task 4).
- 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,
+7 -2
View File
@@ -68,7 +68,7 @@ try {
// the system config, Business can't resolve authority and Settings has // the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor). // no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send. // --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, busLine, system = null; let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, requestRead = null, requestWrite = null, busLine, system = null;
let processing = null, ownerCall = null; let processing = null, ownerCall = null;
try { try {
system = loadSystem(); system = loadSystem();
@@ -79,6 +79,11 @@ try {
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with // Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send. // --sso and --send.
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
// --send. consoleCall refuses any verb that is not a console verb.
if (ssoOn) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
if (ssoOn && sendOn) requestWrite = requestRead;
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) { } catch (err) {
const why = redactor({ configDir: configDir() })(err.message); const why = redactor({ configDir: configDir() })(err.message);
@@ -107,7 +112,7 @@ try {
} }
let server; let server;
try { try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null });
} catch (err) { sso?.close(); throw err; } } catch (err) { sso?.close(); throw err; }
let testerServer = null; let testerServer = null;
if (testerSocket) { if (testerSocket) {
+12
View File
@@ -50,3 +50,15 @@
.chat-room-completion [aria-selected="true"] { outline: 2px solid var(--action); outline-offset: -2px; } .chat-room-completion [aria-selected="true"] { outline: 2px solid var(--action); outline-offset: -2px; }
#chat-room-tags, #chat-room-receipt { overflow-wrap: anywhere; } #chat-room-tags, #chat-room-receipt { overflow-wrap: anywhere; }
#chat-room-tags span { display: inline-block; margin-inline-end: .4rem; } #chat-room-tags span { display: inline-block; margin-inline-end: .4rem; }
/* #797 / #1839: the request intake panel under an owner's chat message (requests.js). */
.request-mount:empty { display: none; }
.request-panel { margin-block-start: .6rem; padding: .7rem .9rem; border: 1px solid var(--line); border-inline-start: 3px solid var(--border); border-radius: .5rem; overflow-wrap: anywhere; }
.request-panel p { margin-block: .35rem; }
.request-panel code, .request-question-text { font-family: var(--mono); }
.request-question-text { white-space: pre-wrap; }
.request-panel textarea { display: block; width: 100%; box-sizing: border-box; margin-block: .35rem; }
.request-choices, .request-picker, .request-options, .request-mode-switch, .request-confirm, .request-cancel { margin-block: .5rem; }
.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; }
+17 -1
View File
@@ -33,6 +33,7 @@
} }
function forget() { function forget() {
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear(); generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
window.MosaicRequests?.forget();
if (current) current.container.replaceChildren(); if (current) current.container.replaceChildren();
current = null; current = null;
} }
@@ -185,9 +186,23 @@
reverts.set(commit, { pending: false, receipt }); reverts.set(commit, { pending: false, receipt });
if (current) { paint(); void loadLibrary(project, generation); } if (current) { paint(); void loadLibrary(project, generation); }
} }
// #797 Task 3: the owner's latest few messages that opened a request carry an empty mount that
// requests.js fills with the intake panel. Older rows stay plain, so a long thread reads few requests.
const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const REQUEST_ROWS = 3;
function requestRows(rows) {
const ids = new Set();
if (!window.MosaicRequests || !roomOwner()) return ids;
for (let i = rows.length - 1; i >= 0 && ids.size < REQUEST_ROWS; i--) {
const m = rows[i];
if (m.from_role === 'human' && typeof m.request === 'string' && REQUEST_ID.test(m.request)) ids.add(m.id);
}
return ids;
}
function conversation(t) { function conversation(t) {
const s = state(current.key); const s = state(current.key);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}</li>`).join(''); const withRequest = requestRows(s.rows);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
const receipt = s.receipt; const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`; return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
} }
@@ -374,6 +389,7 @@
} }
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>'; if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>'; c.container.innerHTML = html + '</div>';
window.MosaicRequests?.mount(c.container);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const log = c.container.querySelector('.chat-log'); const log = c.container.querySelector('.chat-log');
if (log) { if (log) {
+1 -1
View File
@@ -4,7 +4,7 @@
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script> <link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script> <link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head> <script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body> <body>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a> <header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
+417
View File
@@ -0,0 +1,417 @@
/* #797 / #1839 Task 3: the request intake panel under an owner's chat message. The owner picks the mode,
answers the project manager's questions and confirms an attachment with buttons, with no step in a
terminal. chat.js draws an empty `.request-mount` under each of the latest three owner messages that
opened a request and calls MosaicRequests.mount after every paint; this file reads the request
through the Console (src/request-http.mjs) and draws the panel.
The server and the bus check every write again. This is presentation, with four rules of its own:
- Every string from the bus is escaped and its control and bidi characters shown as \uXXXX.
- 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
/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. */
(() => {
'use strict';
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f‪-‮⁦-⁩]/g,
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
const txt = value => esc(inert(value));
const enc = encodeURIComponent;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const STORE = 'mosaic-request-answer:';
const QUESTION_PHASES = ['discovery', 'prd-review', 'planning'];
const MODE_PHASES = [...QUESTION_PHASES, 'implementation', 'review', 'delivery'];
const PICKER_CODES = ['request-existing-project', 'request-attachment-selected'];
const ENDED = ['cancelled', 'delivered'];
const MODES = ['autonomous', 'guided'];
const ANSWER_MAX = 8000, STALE = 5000, PULSE = 10000;
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 panels = new Map();
let signedOut = false, timer = null, config = null, loading = null;
const out = () => signedOut || document.body.classList.contains('signed-out');
const opId = prefix => prefix + '-' + [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('');
// A pending answer, kept per request for the life of the tab. It is written before the send and
// removed once the bus has answered either way, so only a lost response leaves one behind.
const load = id => {
try {
const v = JSON.parse(sessionStorage.getItem(STORE + id));
return v && text(v.op) && ID.test(v.op) && text(v.text) && text(v.question) && ID.test(v.question) && positive(v.revision)
? { op: v.op, text: v.text, question: v.question, revision: v.revision } : null;
} catch { return null; }
};
const save = (id, record) => { try { sessionStorage.setItem(STORE + id, JSON.stringify(record)); } catch { /* kept in memory only */ } };
const drop = id => { try { sessionStorage.removeItem(STORE + id); } catch { /* nothing stored */ } };
const dropAll = () => {
try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(STORE)) sessionStorage.removeItem(key); } catch { /* nothing stored */ }
};
function forget() {
panels.clear();
clearInterval(timer); timer = null;
}
function purge() {
forget(); dropAll(); config = null; loading = null;
for (const el of document.querySelectorAll('.request-mount')) el.replaceChildren();
}
const signOut = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
document.addEventListener('mosaic-signed-out', () => { signedOut = true; purge(); });
// One fetch. A sign-in redirect or a 401 signs the page out; a network failure is `res: null`.
async function call(url, init = {}) {
let res, json = null;
try { res = await fetch(url, { cache: 'no-store', redirect: 'manual', ...init }); } catch { return { res: null, json: null }; }
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return { res, json: null }; }
try { json = await res.json(); } catch { json = null; }
return { res, json };
}
const post = (url, body) => call(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
// /api/config says whether this Console reads requests at all and whether it may write them.
const settings = () => loading ??= call('/api/config').then(({ res, json }) => {
if (res?.ok && json && typeof json === 'object' && !Array.isArray(json)) config = { on: json.requests === true, writes: json.requests === true && json.requestWrites === true };
return config;
}).finally(() => { loading = null; });
function panel(id) {
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 ?? '' } };
panels.set(id, p);
}
return p;
}
const options = list => (Array.isArray(list) ? list : []).filter(o => o && text(o.key) && text(o.text));
const openQuestion = s => QUESTION_PHASES.includes(s.phase) && s.question && text(s.question.id) && ID.test(s.question.id) && text(s.question.text) &&
s.question.answer === null && s.wait?.kind !== 'decision';
const pickerOpen = s => s.phase === 'selection' && s.route === 'existing' && s.wait?.kind === 'setup' && PICKER_CODES.includes(s.wait.code);
// What the state says about a stored answer: still open means the page offers it again; anything
// else means the bus moved on, so the record goes and nothing is sent.
function reconcile(p) {
const rec = p.stored, s = p.state;
if (!rec || !s) return;
const q = s.question;
if (q && q.id === rec.question && q.answer === null && QUESTION_PHASES.includes(s.phase)) return;
p.stored = null; drop(p.id);
if (p.ui.draft === rec.text) p.ui.draft = '';
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) p.state = s;
p.missing = false; p.failed = false; p.stale = false;
reconcile(p);
}
const WAITS = {
'request-mode': 'Choose how to run this request.',
'request-existing-project': 'Choose the project this request goes into.',
question: 'The project manager is waiting for your answer.',
};
function waitHtml(s) {
const w = s.wait;
if (!w || !text(w.code)) return '';
const line = w.code === 'request-attachment-selected'
? `You picked project <code>${txt(w.subject)}</code>. Waiting for the project manager to propose it for your confirmation.`
: WAITS[w.code] ? esc(WAITS[w.code]) : `Waiting (${txt(w.kind)}): ${txt(w.code)}`;
return `<p class="request-wait">${line}</p>`;
}
function html(p) {
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>` : '';
const busy = ui.pending ? ' disabled' : '', can = p.writes;
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>`;
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
body += waitHtml(s);
if (q) {
body += q.answer === null
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
: `<p class="request-question answered">Answered: ${txt(q.text)}</p>`;
}
if (!can) body += '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
if (can && 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>
<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" data-request-unconfirm${busy}>Back</button></div>`;
} else if (can && s.phase === 'selection' && 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 =>
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
}
if (can && pickerOpen(s)) {
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
body += Array.isArray(p.projects)
? `<div class="request-picker" role="group" aria-label="Choose a project">${p.projects.length ? `<p class="small muted">Projects you may use for this request. Picking one asks the project manager to propose it; you confirm it next.</p>${p.projects.map(x =>
`<button type="button" class="btn" data-request-project="${esc(x.project)}" aria-pressed="${x.project === picked}"${busy}>${txt(x.name)} <small>(${txt(x.project)})</small></button>`).join(' ')}` : '<p>No project is available to you for this request.</p>'}</div>`
: '<p class="request-picker-note">The projects you may use could not be read. Use Read again.</p>';
}
if (can && openQuestion(s)) {
const lost = p.stored && p.stored.question === s.question.id;
body += `<form class="request-answer" novalidate><label for="chat-request-answer-${esc(p.id)}">Your answer</label>
${options(q.options).length ? `<p class="small muted">Suggested answers fill the box. They do not send.</p><div class="request-options">${options(q.options).map(o =>
`<button type="button" class="btn" data-request-option="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>` : ''}
<textarea id="chat-request-answer-${esc(p.id)}" rows="4" autocomplete="off"${busy}>${esc(ui.draft)}</textarea>
<p class="small muted">A text answer is not an approval. It goes to the project manager as your reply; approvals are separate buttons.</p>
${lost ? `<p class="request-lost">A previous answer may not have arrived. Your text is below. Sending the same answer again uses the same operation, so the bus records it once.${ui.draft === p.stored.text ? ` <button type="button" class="btn" data-request-resend${busy}>Send the same answer again</button>` : ''}</p>` : ''}
<button type="submit" class="btn primary"${busy}>${ui.pending === 'answer' ? 'Sending…' : 'Send answer'}</button></form>`;
}
if (can && s.mode && MODE_PHASES.includes(s.phase) && positive(s.mode_revision)) {
body += `<div class="request-mode-switch"><p class="small muted">Changing the mode withdraws any approval waiting in the old mode. It takes effect at the next step.</p>${MODES.filter(m => m !== s.mode).map(m =>
`<button type="button" class="btn" data-request-mode="${esc(m)}"${busy}>Switch to ${txt(m)}</button>`).join(' ')}</div>`;
}
if (can && !ENDED.includes(s.phase)) {
body += `<div class="request-cancel">${ui.cancelAsk
? `<p>Cancel this request? Any project already created is kept as evidence and is not deleted.</p>
<button type="button" class="btn" data-request-cancel-confirm${busy}>Yes, cancel the request</button> <button type="button" class="btn" data-request-cancel-keep${busy}>Keep it going</button>`
: `<button type="button" class="btn" data-request-cancel${busy}>Cancel this request</button>`}</div>`;
}
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}<button type="button" class="btn" data-request-reload${busy}>Read again</button></p>`;
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
}
// Redraw every mount of this request, keeping the keyboard where it was.
function draw(p) {
if (out() || panels.get(p.id) !== p) return;
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 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');
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]);
// 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;
if (!p.writes) { p.projects = null; return; }
if (s.phase === 'selection' && text(s.selection?.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;
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)) {
const { res, json } = await call(`/api/requests/${enc(p.id)}/projects`);
if (out() || panels.get(p.id) !== p) return;
p.projects = res?.ok && Array.isArray(json) && json.every(x => x && text(x.project) && text(x.name)) ? json : 'failed';
} else p.projects = null;
}
function read(p) {
if (out()) return null;
if (p.reading) return p.reading;
p.reading = (async () => {
try {
const { res, json } = await call(`/api/requests/${enc(p.id)}`);
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 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); }
} finally { p.reading = null; }
})();
return p.reading;
}
// One write. `null` means the page signed out or moved on, so the caller does nothing.
async function send(p, action, body) {
p.ui.pending = action; draw(p);
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 };
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; }
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.';
p.sig = signature(p); draw(p);
if (!r.ok) void read(p);
return r.ok === true;
}
async function answer(p, again = false) {
const s = p.state, ui = p.ui;
if (!p.writes || ui.pending || !s || !openQuestion(s)) return;
let rec;
if (again) {
rec = p.stored;
if (!rec || rec.question !== s.question.id) return;
} else {
const draft = ui.draft;
if (draft.trim() === '') { ui.receipt = 'Write an answer first.'; draw(p); return; }
if (draft.length > ANSWER_MAX) { ui.receipt = `That answer is too long (${draft.length} of ${ANSWER_MAX} characters). Shorten it.`; draw(p); return; }
rec = { op: opId('answer'), text: draft, question: s.question.id, revision: s.revision };
p.stored = rec; save(p.id, rec);
}
ui.receipt = '';
const r = await send(p, 'answer', { revision: rec.revision, question: rec.question, op: rec.op, text: rec.text });
if (!r) return;
// Only a lost response keeps the record: the bus answered, one way or the other, in every other case.
if (!r.unknown) { p.stored = null; drop(p.id); }
if (settle(p, r, 'Your answer was recorded. The project manager continues from it.', 'Not sent')) ui.draft = '';
if (r.ok) draw(p);
}
async function attach(p, project) {
const s = p.state;
if (!p.writes || p.ui.pending || !s || !pickerOpen(s) || !Array.isArray(p.projects) || !p.projects.some(x => x.project === project)) return;
p.ui.receipt = '';
settle(p, await send(p, 'attach', { revision: s.revision, project, op: opId('attach') }),
`Picked ${project}. The project manager will propose it for your confirmation.`, 'Not picked');
}
async function mode(p, next) {
const s = p.state;
if (!p.writes || p.ui.pending || !s || !MODES.includes(next) || next === s.mode || !MODE_PHASES.includes(s.phase) || !positive(s.mode_revision)) return;
p.ui.receipt = '';
settle(p, await send(p, 'mode', { revision: s.revision, mode_revision: s.mode_revision, mode: next, op: opId('mode') }),
`Mode is now ${next}. An approval waiting in the old mode is withdrawn at the next step.`, 'Mode not changed');
}
async function cancel(p) {
const s = p.state;
if (!p.writes || p.ui.pending || !s || ENDED.includes(s.phase)) return;
p.ui.receipt = ''; p.ui.cancelAsk = false;
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
// the answer; the second records it. A refusal drops the confirmation and an unknown outcome is
// reported, never repeated.
const DECIDE_FALLBACK = {
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
'outcome-unknown': 'The answer may have reached the bus. Read the request before trying again.',
};
const refusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && text(json?.error) && text(json?.message)
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
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;
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 };
draw(p);
} else {
ui.receipt = notAnswered(refusal(res, json, 'confirmation-unavailable')); draw(p);
void read(p);
}
}
async function confirm(p) {
const c = p.ui.confirm, ui = p.ui;
if (!p.writes || ui.pending || !c) 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;
// One use, whatever came back.
ui.pending = null; ui.confirm = null;
ui.receipt = res?.ok && json?.id === c.id && json.op === 'resolved' && json.choice === c.choice
? `Recorded: you chose “${c.text}”. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
draw(p);
void read(p);
}
document.addEventListener('click', event => {
const button = event.target.closest?.('button');
const el = button?.closest('.request-panel');
if (!button || !el || out()) return;
const p = panels.get(el.dataset.request);
if (!p || button.disabled) 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);
else if ('requestConfirm' in d) void confirm(p);
else if ('requestUnconfirm' in d) { p.ui.confirm = null; p.ui.receipt = ''; draw(p); }
else if ('requestProject' in d) void attach(p, d.requestProject);
else if ('requestOption' in d) {
const o = options(p.state?.question?.options).find(x => x.key === d.requestOption);
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 ('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');
if (!form) return;
event.preventDefault();
const p = panels.get(form.closest('.request-panel')?.dataset.request);
if (p && !out()) 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;
});
// The Console does not push request changes, so a shown panel re-reads on a slow beat while the page
// is visible. The beat stops when no panel is on the page and never runs after sign-out.
function pulse() {
if (timer || out()) return;
timer = setInterval(() => {
if (out() || document.hidden) return;
const shown = [...document.querySelectorAll('.request-mount[data-request]')];
if (!shown.length) { clearInterval(timer); timer = null; return; }
for (const el of shown) {
const p = panels.get(el.dataset.request);
if (p?.state && !p.ui.pending && !ENDED.includes(p.state.phase)) void read(p);
}
}, PULSE);
}
// Called by chat.js after each paint with the chat container. A panel draws from what it already
// knows at once, so a repaint never blanks it, and reads again only when its state is stale.
function mount(container) {
if (out() || !container) return;
const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request));
if (!mounts.length) return;
const run = cfg => {
if (out() || !cfg?.on) return;
for (const el of mounts) {
if (!el.isConnected) continue;
const p = panel(el.dataset.request);
p.writes = cfg.writes;
if (p.state || p.failed) draw(p);
if (!p.reading && Date.now() - p.at > STALE) void read(p);
}
pulse();
};
if (config) run(config);
else void settings().then(run);
}
window.MosaicRequests = Object.freeze({ mount, forget });
})();
+169
View File
@@ -0,0 +1,169 @@
// #797 / #1839 Task 3: the Console's request intake routes. Two reads and four 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 (Task 4; fails closed until it exists)
// 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 }
// 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
// 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))?$/;
const WRITE_VERBS = Object.freeze({
answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.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`,
// is refused: the route and the session say who and what.
const FIELDS = Object.freeze({
answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'],
mode: ['revision', 'mode_revision', 'mode', 'op'], cancel: ['revision', 'op'],
});
// The endpoint contract. Each refusal is one of these, never a 200.
export const REQUEST_REFUSALS = Object.freeze({
'sign-in-required': [401, 'Sign in again before changing this request.'],
'read-only': [403, 'Console request controls are not enabled.'],
'cross-origin': [403, 'Act from the signed-in Console page.'],
'not-found': [404, 'Not found.'],
'human-required': [403, 'The bus refused this human identity.'],
'peercred-required': [403, 'The bus could not verify this connection.'],
'method-not-allowed': [405, 'Use GET to read and POST to change a request.'],
'invalid-request': [400, 'Invalid request change.'],
'request-too-large': [413, 'The request change is too large.'],
'request-not-found': [404, 'This request is unavailable.'],
'request-revision-conflict': [409, 'The request changed. Read it again.'],
'request-phase-refused': [409, 'The request is not at a step that takes this.'],
'request-mode-stale': [409, 'The mode changed. Read the request again.'],
'request-mode-unchanged': [409, 'The request is already in that mode.'],
'request-question-closed': [409, 'That question is already answered.'],
'request-question-mismatch': [409, 'That is not the question the project manager is waiting on.'],
'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.'],
'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.'],
});
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any other
// code, or no answer, is `unavailable` at a read and `outcome-unknown` at a write: the write may have
// landed, so the page reads the request first and never re-sends on its own.
const BUS_CODES = {
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
'peercred-required': 'peercred-required',
'request-not-found': 'request-not-found', 'request-revision-conflict': 'request-revision-conflict',
'request-phase-refused': 'request-phase-refused', 'request-mode-stale': 'request-mode-stale',
'request-mode-unchanged': 'request-mode-unchanged', 'request-question-closed': 'request-question-closed',
'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused',
'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request',
};
const BODY_MAX = 4096;
const ANSWER_BODY_MAX = 65536;
const ANSWER_MAX = 8000;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const MODES = ['autonomous', 'guided'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
const positive = x => Number.isSafeInteger(x) && x > 0;
export function requestRefuse(res, code) {
const [status, message] = REQUEST_REFUSALS[code];
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ error: code, message }));
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
async function readBody(req, max) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > max) return { code: 'request-too-large' };
chunks.push(chunk);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
} catch { return { code: 'invalid-request' }; }
}
// The fields of one write, or null. Everything the bus checks again is checked here first so a
// malformed body never spawns a transport.
function fields(action, value) {
const keys = Object.keys(value);
if (keys.length !== FIELDS[action].length || !FIELDS[action].every(k => keys.includes(k))) return null;
if (!positive(value.revision) || typeof value.op !== 'string' || !ID.test(value.op)) return null;
if (action === 'answer') {
if (typeof value.question !== 'string' || !ID.test(value.question)) return null;
if (typeof value.text !== 'string' || value.text.trim().length === 0 || value.text.length > ANSWER_MAX || value.text.includes('\0')) 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;
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';
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
// before each spawn. `requestRead(verb, args, { subject })` and `requestWrite(...)` are the console
// transports; either is null when the Console has no such transport. `verbs` is the console verb
// list: the draft read exists only while it names `request.draft.console`.
export async function requestHTTP(req, res, { path, requestRead, requestWrite, session, live, origin, log, verbs = CONSOLE_VERBS }) {
const [, raw, suffix] = REQUEST_ROUTE.exec(path);
const action = suffix ?? 'get';
const write = Object.hasOwn(WRITE_VERBS, action);
let id = null;
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
if (req.method !== (write ? 'POST' : 'GET')) return requestRefuse(res, 'method-not-allowed');
if (write ? !requestWrite : !requestRead) return requestRefuse(res, write ? 'read-only' : 'unavailable');
if (!session) return requestRefuse(res, 'sign-in-required');
// A write needs the Console's own Origin and same-origin fetch metadata. A read refuses another
// site's, and lets a plain navigation (no origin, no metadata) through.
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (write ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return requestRefuse(res, 'cross-origin');
const user = JSON.stringify(String(session.name ?? '').slice(0, 64));
const line = tail => log(`${new Date().toISOString()} request user=${user} request=${JSON.stringify(String(id ?? '').slice(0, 64))} action=${action}${tail}`);
const refuse = (code, extra = '') => { line(`${extra} error=${code}`); return requestRefuse(res, code); };
if (id === null || !ID.test(id)) return refuse('request-not-found');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
const busCode = (err, open) => (err instanceof BusReadError ? BUS_CODES[err.code] : undefined) ?? open;
if (!write) {
const verb = READ_VERBS[action];
if (action === 'draft' && !verbs.includes(verb)) return refuse('draft-unavailable');
if (live() !== session) return refuse('sign-in-required');
let reply;
try { reply = await requestRead(verb, { request: id }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable')); }
if (action === 'projects') {
if (!picker(reply)) return refuse('unavailable');
line('');
return json(res, 200, reply.map(({ project, name }) => ({ project, name })));
}
if (action === 'draft' ? !(reply && typeof reply === 'object' && !Array.isArray(reply) && reply.request === id) : !state(reply, id)) return refuse('unavailable');
line('');
return json(res, 200, reply);
}
const limit = action === 'answer' ? ANSWER_BODY_MAX : BODY_MAX;
const { value, code } = await readBody(req, limit);
if (code) return refuse(code);
const args = fields(action, value);
if (!args) return refuse('invalid-request');
const tail = ` revision=${args.revision}`;
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);
line(`${tail} outcome=ok`);
return json(res, 200, reply);
}
+18 -5
View File
@@ -13,6 +13,7 @@ import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs'; import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs'; import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) { export function isLoopback(host) {
@@ -32,7 +33,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']], ['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy', ['proxy.html', 'text/html; charset=utf-8']], ['/proxy', ['proxy.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']], ['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']], ['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js. // Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
@@ -653,7 +654,7 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json. // tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1'; if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -666,6 +667,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!tester && testerSocket) throw new Error('tester socket requires tester listener'); if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester. // Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener'); if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
const confirmations = createConfirmations(); const confirmations = createConfirmations();
const upstream = boardURL(board); const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
@@ -725,9 +729,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C). // alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' }); if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out. // No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path); const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' }); return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null; let signer = preview, ssoSession = null;
if (sso !== null && named) { if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search); if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
@@ -735,6 +739,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) }); const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') { if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required'); if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' }); if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' }); res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end(); return res.end();
@@ -774,6 +779,14 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null; const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log }); return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
} }
// #797 / #1839 Task 3: the owner's request intake controls. The tester listener never has them, and a
// granted user stopped at userRoute above. Reads need the read transport; the writes also --send.
if (requestPath) {
if (tester) return requestRefuse(res, 'not-found');
const ready = named && sso !== null;
return requestHTTP(req, res, { path, requestRead: ready ? requestRead : null, requestWrite: ready && send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin, log });
}
// Row 194 (#1692, D4.1): the tester also launches and terminates. // Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false }); return json(res, 403, { error: 'read-only', sent: false });
@@ -907,7 +920,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on). // `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's. // `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named }); if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path); const file = files.get(path);
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()'); await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, same origin; the list is the pin, so another script is a visible change. // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false); assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,372 @@
// #797 / #1839 Task 3: the intake panel on an owner's chat thread, driven in the installed Chromium
// against a stubbed fetch (the pattern of chat-browser.test.mjs). The panel is requests.js. The
// server side is request-http.test.mjs and the bus side packages/bus/tests/request-console.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
import { browser } from './browser.mjs';
const A = '20000000-0000-4000-8000-000000000002';
const M = '40000000-0000-4000-8000-000000000004';
const R = '50000000-0000-4000-8000-000000000005';
const D = '60000000-0000-4000-8000-000000000006';
const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m‮evil';
const state = (o = {}) => ({
version: 1, request: R, business: 'demo', revision: 4, source_message: M, thread: A, session: null, owner: 'jason', pm: 'pm', phase: 'selection',
project: null, chat_project: null, mode: null, mode_revision: null, plan_digest: null, spec_commit: null, snapshot: null, tasks: [], delivery: null,
question: null, candidate: null, selection: null, route: null, wait: null, ...o,
});
const wait = (kind, code, subject, o = {}) => ({ kind, code, subject, resume_phase: 'selection', observed_revision: 4, ...o });
const CHOOSING = state({ selection: { decision: D, bindings: [] }, wait: wait('decision', 'request-mode', D) });
const PICKING = state({ route: 'existing', wait: wait('setup', 'request-existing-project', R) });
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 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' }] } }];
const stubFor = boot => `(() => {
try { if (!sessionStorage.getItem('__boot')) sessionStorage.setItem('__boot', ${JSON.stringify(JSON.stringify(boot))}); } catch {}
const boot = JSON.parse(sessionStorage.getItem('__boot') || '{}');
const real = fetch.bind(window);
window.__rreads = []; window.__rwrites = []; window.__rAnswers = [];
window.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || [];
window.__readStatus = {};
const flags = { requests: !location.search.includes('norequests'), requestWrites: !location.search.includes('readonly') };
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
if (path === '/api/config') return json({ send: true, ...(flags.requests ? { requests: true, requestWrites: flags.requestWrites } : {}) });
if (path === '/api/bus/inbox') return json({ rows: window.__inbox, unread: 0, at: new Date().toISOString() });
if (path.startsWith('/api/bus/')) return json({ rows: [], unread: 0, at: new Date().toISOString() });
if (path === '/api/business') return json({ projects: ['stack'] });
if (path.startsWith('/api/requests/') || path.startsWith('/api/decisions/')) {
if (method === 'POST') {
window.__rwrites.push({ path, body: JSON.parse(init.body), redirect: init.redirect });
const a = window.__rAnswers.shift(); if (!a) throw new Error('No write answer');
if (a.hold) await new Promise(r => window.__releaseR = r);
if (a.reject) throw new TypeError('network');
if (a.set) window.__states[a.set.request] = a.set;
return json(a.body ?? a.set, a.status || 200);
}
window.__rreads.push(path);
const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects))?$/.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] === '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);
}
if (!path.startsWith('/api/chat')) return real(url, init);
const data = value => json({ ...value, at: new Date().toISOString() });
if (method === 'POST') return json({ id: 'sent' });
if (path === '/api/chat/instances') return data({ rows: [{ instance: 'assistant', name: 'assistant' }] });
if (path === '/api/chat/projects') return data({ rows: [] });
if (path.endsWith('/sessions')) { const t = path.split('/')[4]; return data({ rows: [{ id: t, name: 'default', default: true }] }); }
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return data({ rows: [{ id: '${A}', project: '${A}', name: 'alpha', instance: 'assistant' }] });
if (window.__pages.length) return data(window.__pages.length > 1 && !window.__stay ? window.__pages.shift() : window.__pages[0]);
return data({ rows: [], after: null, hasMore: false });
};
})();`;
async function setup(t, { boot = {}, suffix = '' } = {}) {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor({ pages: [{ rows: [ROW, { id: D, body: 'assistant reply', from_role: 'assistant', at: '2026-11-03T00:00:01Z' }], after: D, hasMore: false }], states: { [R]: ASKING }, ...boot }) });
const url = `http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`;
await b.navigate(url);
const w = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`);
const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`);
const count = selector => b.evaluate(`document.querySelectorAll(${JSON.stringify(selector)}).length`);
const writes = () => b.evaluate('window.__rwrites');
const answer = (...a) => b.evaluate(`window.__rAnswers.push(...${JSON.stringify(a)}),true`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const reload = async () => {
await b.evaluate('window.__old=true,true');
await b.call('Page.reload', { ignoreCache: true });
for (let i = 0; i < 100; i++) { try { if (await b.evaluate('!window.__old && document.readyState==="complete"')) break; } catch { /* the old page is going away */ } await sleep(50); }
};
await w('document.querySelector(".chat-conversation")');
return { b, w, set, click, text, count, writes, answer, reload, sleep };
}
const P = `.request-panel[data-request="${R}"]`;
test('request panel: mounts under the owner message that opened the request, reads only, and renders hostile data inert', async t => {
const hostile = state({ revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1,
question: { id: 'q-1', text: HOSTILE, options: [{ key: 'web', text: HOSTILE }], answer: null }, wait: wait('question', 'question', HOSTILE, { resume_phase: 'discovery' }) });
const { b, w, count, text, writes } = await setup(t, { boot: { states: { [R]: hostile } } });
await w(`document.querySelector('${P} .request-question')`);
assert.equal(await count('.request-mount'), 1, 'one panel, for the one human row that carries a request');
assert.equal(await b.evaluate(`!!document.querySelector('[data-message="${M}"] .request-mount')`), true, 'inside the message that opened it');
assert.equal(await b.evaluate(`!!document.querySelector('[data-message="${D}"] .request-mount')`), false, 'no panel on the assistant reply');
assert.deepEqual(await writes(), [], 'mounting writes nothing');
assert.equal(await b.evaluate('window.__rreads.filter(p => p.startsWith("/api/requests/")).length >= 1'), true);
assert.equal(await b.evaluate('window.injected'), undefined);
assert.equal(await b.evaluate(`!!document.querySelector('${P} img')`), false);
const panel = await text(P);
assert.match(panel, /\\u202e/); assert.doesNotMatch(panel, /[‪-‮⁦-⁩]/u);
assert.match(panel, /\\u001b/);
assert.match(await text(`${P} .request-phase`), /discovery/);
});
test('request panel: only the latest three owner messages get a panel, and a request the bus does not know shows nothing', async t => {
const ids = [1, 2, 3, 4, 5].map(i => `5000000${i}-0000-4000-8000-000000000005`);
const rows = ids.map((request, i) => ({ id: `4000000${i}-0000-4000-8000-000000000004`, body: `m${i}`, from_role: 'human', at: '2026-11-03T00:00:00Z', request }));
const states = Object.fromEntries(ids.slice(1).map(id => [id, { ...ASKING, request: id }]));
const { b, w, count } = await setup(t, { boot: { pages: [{ rows, after: rows.at(-1).id, hasMore: false }], states } });
await w('document.querySelectorAll(".request-panel").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".request-mount")].map(e => e.dataset.request)'), ids.slice(2));
assert.equal(await b.evaluate('window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).length'), 3);
// The third newest is unknown to the bus: no panel, no error banner.
const gone = await setup(t, { boot: { pages: [{ rows: [ROW], after: M, hasMore: false }], states: {} } });
await gone.w(`window.__rreads.some(p => p === "/api/requests/${R}")`);
await gone.sleep(150);
assert.equal(await gone.count('.request-panel'), 0);
assert.equal(await gone.count('.chat-error'), 0);
assert.ok(await count('.request-panel') >= 3);
});
test('request panel: a Console without request support shows nothing, and one that cannot write shows the state without controls', async t => {
const off = await setup(t, { suffix: '?norequests' });
await off.sleep(300);
assert.equal(await off.count('.request-mount .request-panel'), 0);
assert.equal(await off.b.evaluate('window.__rreads.some(p => p.startsWith("/api/requests/"))'), false, 'no request read when the Console says it has none');
const ro = await setup(t, { suffix: '?readonly', boot: { states: { [R]: ASKING } } });
await ro.w(`document.querySelector('${P} .request-question')`);
assert.equal(await ro.count(`${P} form, ${P} textarea, ${P} [data-request-mode], ${P} [data-request-cancel], ${P} [data-request-project], ${P} [data-request-choice]`), 0);
assert.match(await ro.text(`${P} .request-readonly`), /read-only|cannot answer/i);
assert.deepEqual(await ro.writes(), []);
});
test('request panel: the owner picks the mode with buttons through the decision confirmation, nothing is answered by the first click', async t => {
const { b, w, click, count, text, writes, answer } = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: INBOX } });
await w(`document.querySelector('${P} [data-request-choice]')`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${P} [data-request-choice]')].map(e => e.dataset.requestChoice)`), ['autonomous', 'guided', 'existing']);
assert.match(await text(`${P} [data-request-choice="guided"]`), /New project — guided/);
await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'guided', text: 'New project — guided', question: 'Confirm the resource plan.', confirmation: 'tok-1' } });
await click(`${P} [data-request-choice="guided"]`);
await w(`document.querySelector('${P} [data-request-confirm]')`);
assert.deepEqual(await writes(), [{ path: `/api/decisions/${D}/confirmation`, body: { choice: 'guided' }, redirect: 'manual' }], 'the first click only previews');
assert.match(await text(`${P} .request-confirm`), /New project — guided/);
// Cancelling the confirmation answers nothing and brings the choices back.
await click(`${P} [data-request-unconfirm]`);
await w(`document.querySelector('${P} [data-request-choice]')`);
assert.equal((await writes()).length, 1);
await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'autonomous', text: 'New project — autonomous', question: 'q', confirmation: 'tok-2' } });
await click(`${P} [data-request-choice="autonomous"]`);
await w(`document.querySelector('${P} [data-request-confirm]')`);
const next = state({ revision: 6, phase: 'bootstrap', project: 'timer', mode: 'autonomous', mode_revision: 1, plan_digest: 'a'.repeat(64) });
await b.evaluate(`window.__states['${R}']=${JSON.stringify(next)},true`);
await answer({ body: { id: D, op: 'resolved', choice: 'autonomous' } });
await click(`${P} [data-request-confirm]`);
await w(`/bootstrap/.test(document.querySelector('${P} .request-phase')?.textContent)`);
assert.deepEqual((await writes()).map(x => [x.path, x.body]), [
[`/api/decisions/${D}/confirmation`, { choice: 'guided' }],
[`/api/decisions/${D}/confirmation`, { choice: 'autonomous' }],
[`/api/decisions/${D}/resolve`, { choice: 'autonomous', confirmation: 'tok-2' }],
]);
assert.equal(await count(`${P} [data-request-choice]`), 0, 'a decided selection offers no choices');
});
test('request panel: a refused or lost confirmation says so and is never retried', async t => {
const { b, w, click, text, writes, answer } = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: INBOX } });
await w(`document.querySelector('${P} [data-request-choice]')`);
await answer({ status: 409, body: { error: 'decision-stale', message: 'The decision changed.' } });
await click(`${P} [data-request-choice="guided"]`);
await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('decision-stale')`);
assert.match(await text(`${P} .request-receipt`), /Not answered/);
assert.equal((await writes()).length, 1);
await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'guided', text: 'g', question: 'q', confirmation: 'tok-3' } }, { reject: true });
await click(`${P} [data-request-choice="guided"]`);
await w(`document.querySelector('${P} [data-request-confirm]')`);
await click(`${P} [data-request-confirm]`);
await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`);
assert.equal((await writes()).length, 3, 'the lost resolve is not sent again');
assert.equal(await b.evaluate(`!!document.querySelector('${P} [data-request-confirm]')`), false, 'a confirmation works once');
// A granted-eligibility mismatch: an inbox row that is not a plain decision gets no buttons.
const odd = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: [{ ...INBOX[0], console: { kind: 'grant', digest: 'd'.repeat(64) } }] } });
await odd.w(`document.querySelector('${P} .request-wait')`);
assert.equal(await odd.count(`${P} [data-request-choice]`), 0);
});
test('request panel: attach shows the authorized projects, picks one with a button, and lets the owner change the pick', async t => {
const projects = [{ project: 'old', name: HOSTILE }, { project: 'older', name: 'Older' }];
const { b, w, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: PICKING }, projects } });
await w(`document.querySelector('${P} [data-request-project="old"]')`);
assert.equal(await count(`${P} [data-request-project]`), 2);
assert.equal(await b.evaluate(`!!document.querySelector('${P} img')`), false);
assert.equal(await b.evaluate('window.injected'), undefined);
assert.match(await text(`${P} [data-request-project="old"]`), /\\u202e/);
const picked = state({ revision: 5, route: 'existing', wait: wait('setup', 'request-attachment-selected', 'old', { observed_revision: 5 }) });
await answer({ set: picked });
await click(`${P} [data-request-project="old"]`);
await w(`/old/.test(document.querySelector('${P} .request-wait')?.textContent) && /project manager/i.test(document.querySelector('${P} .request-wait')?.textContent)`);
const [w1] = await writes();
assert.equal(w1.path, `/api/requests/${R}/attach`);
assert.deepEqual({ ...w1.body, op: undefined }, { revision: 4, project: 'old', op: undefined });
assert.match(w1.body.op, /^attach-[0-9a-f]{24}$/);
assert.equal(w1.redirect, 'manual');
assert.equal(await count(`${P} [data-request-project]`), 2, 'the owner can still change the pick before the PM proposes');
// Changing the pick is a new action with a new op and the new revision.
await answer({ set: { ...picked, revision: 6, wait: wait('setup', 'request-attachment-selected', 'older', { observed_revision: 6 }) } });
await click(`${P} [data-request-project="older"]`);
await w('window.__rwrites.length === 2');
const second = (await writes())[1];
assert.deepEqual([second.body.revision, second.body.project], [5, 'older']);
assert.notEqual(second.body.op, w1.body.op);
// The project list failing says so and draws no buttons.
const down = await setup(t, { boot: { states: { [R]: PICKING }, projects } });
await down.b.evaluate('window.__projectsFail=true,document.querySelector("#refresh")?.click(),true');
await down.sleep(100);
assert.ok(await down.count(`${P} [data-request-project]`) >= 0);
});
test('request panel: a guided answer is free text sent with its question, op and revision, and is never an approval', async t => {
const { b, w, set, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} form.request-answer textarea')`);
assert.match(await text(`${P} .request-question`), /Which platform\?/);
assert.match(await text(P), /not an approval/i);
await click(`${P} [data-request-option="cli"]`);
assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Command line', 'an option fills the text, it does not send');
assert.deepEqual(await writes(), []);
const body = 'Web, but offline.\nSecond line <b>x</b>';
await set(`${P} textarea`, body);
const done = state({ ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-1' }, wait: null });
await answer({ set: done });
await click(`${P} form.request-answer button[type=submit]`);
await w(`!document.querySelector('${P} form.request-answer')`);
const [sent] = await writes();
assert.equal(sent.path, `/api/requests/${R}/answer`);
assert.deepEqual({ ...sent.body, op: undefined }, { revision: 7, question: 'q-1', text: body, op: undefined });
assert.match(sent.body.op, /^answer-[0-9a-f]{24}$/);
assert.match(await text(`${P} .request-receipt`), /answer.*(recorded|sent)/i);
assert.equal(await b.evaluate(`[...document.querySelectorAll('${P} b')].some(e => e.textContent === 'x')`), false, 'typed markup is never parsed');
assert.equal(await count(`${P} [data-request-choice]`), 0, 'a text answer opens no decision');
assert.equal(await b.evaluate('window.__rwrites.some(x => x.path.startsWith("/api/decisions/"))'), false);
assert.equal(await b.evaluate('sessionStorage.length === 1 || [...Array(sessionStorage.length)].every((_, i) => !sessionStorage.key(i).startsWith("mosaic-request-answer"))'), true, 'a recorded answer leaves no pending record');
});
test('request panel: empty text, a long answer and a stale revision never send blind', async t => {
const { b, w, set, click, text, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} form.request-answer textarea')`);
await click(`${P} form.request-answer button[type=submit]`);
assert.deepEqual(await writes(), []);
assert.match(await text(`${P} .request-receipt`), /Write an answer/i);
await set(`${P} textarea`, 'x'.repeat(8001)); await click(`${P} form.request-answer button[type=submit]`);
assert.deepEqual(await writes(), []);
assert.match(await text(`${P} .request-receipt`), /too long/i);
await set(`${P} textarea`, 'keep me');
const moved = state({ ...ASKING, revision: 9 });
await b.evaluate(`window.__states['${R}']=${JSON.stringify(moved)},true`);
const reads = await b.evaluate('window.__rreads.length');
await answer({ status: 409, body: { error: 'request-revision-conflict', message: 'The request changed.' } });
await click(`${P} form.request-answer button[type=submit]`);
await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('changed')`);
assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'keep me', 'the draft survives a stale revision');
await w(`window.__rreads.length > ${reads}`);
await w(`window.__states['${R}'].revision === 9 && document.querySelector('${P} form.request-answer')`);
assert.equal((await writes()).length, 1, 'the stale write is not retried');
await answer({ set: { ...moved, revision: 10, question: { ...QUESTION, answer: 'ev-2' }, wait: null } });
await click(`${P} form.request-answer button[type=submit]`);
await w('window.__rwrites.length === 2');
assert.equal((await writes())[1].body.revision, 9, 'the retry is a deliberate click at the re-read revision');
assert.notEqual((await writes())[1].body.op, (await writes())[0].body.op, 'a refused write consumed no op');
});
test('request panel: a lost answer response is read first after a reload, and only a click sends again with the same op', async t => {
const { b, w, set, click, text, count, writes, answer, reload } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} form.request-answer textarea')`);
await set(`${P} textarea`, 'Web please');
await answer({ reject: true });
await click(`${P} form.request-answer button[type=submit]`);
await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`);
const first = (await writes())[0];
assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Web please', 'the draft is kept');
assert.equal((await writes()).length, 1, 'nothing is sent again by itself');
// The page reloads. The state still shows the question open: the page reads it, offers the stored text, and sends nothing.
await reload();
await w(`document.querySelector('${P} form.request-answer textarea')`);
assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Web please');
assert.match(await text(P), /may not have arrived|may have reached/i);
assert.deepEqual(await writes(), [], 'no blind repost after a reload');
assert.equal(await b.evaluate(`window.__rreads.some(p => p === "/api/requests/${R}")`), true, 'the state was read first');
await answer({ set: { ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-3' }, wait: null } });
await click(`${P} [data-request-resend]`);
await w('window.__rwrites.length === 1');
const again = (await writes())[0];
assert.equal(again.body.op, first.body.op, 'the same op, so the bus cannot record it twice');
assert.deepEqual([again.body.question, again.body.text, again.body.revision], ['q-1', 'Web please', 7]);
await w(`!document.querySelector('${P} form.request-answer')`);
assert.equal(await count(`${P} [data-request-resend]`), 0);
});
test('request panel: a lost answer that did land is recognised on reload, with no send and no pending record', async t => {
const { b, w, set, click, text, writes, answer, reload } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} form.request-answer textarea')`);
await set(`${P} textarea`, 'It landed');
await answer({ reject: true });
await click(`${P} form.request-answer button[type=submit]`);
await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`);
assert.equal(await b.evaluate('[...Array(sessionStorage.length)].some((_, i) => sessionStorage.key(i).startsWith("mosaic-request-answer"))'), true, 'the pending answer is stored before the send');
await b.evaluate(`sessionStorage.setItem('__boot', JSON.stringify({ pages: [{ rows: [${JSON.stringify(ROW)}], after: '${M}', hasMore: false }], states: { '${R}': ${JSON.stringify({ ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-4' }, wait: null })} } })),true`);
await reload();
await w(`document.querySelector('${P} .request-phase')`);
assert.equal(await b.evaluate(`!!document.querySelector('${P} form.request-answer')`), false);
assert.match(await text(P), /answered/i);
assert.deepEqual(await writes(), []);
assert.equal(await b.evaluate('[...Array(sessionStorage.length)].some((_, i) => sessionStorage.key(i).startsWith("mosaic-request-answer"))'), false);
});
test('request panel: a mode change names the expected revisions, cancel asks first and keeps the project', async t => {
const { b, w, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} [data-request-mode]')`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${P} [data-request-mode]')].map(e => e.dataset.requestMode)`), ['autonomous'], 'only the other mode is offered');
await answer({ set: { ...ASKING, revision: 8, mode: 'autonomous', mode_revision: 2 } });
await click(`${P} [data-request-mode="autonomous"]`);
await w(`document.querySelector('${P} [data-request-mode="guided"]')`);
const [m] = await writes();
assert.equal(m.path, `/api/requests/${R}/mode`);
assert.deepEqual({ ...m.body, op: undefined }, { revision: 7, mode_revision: 1, mode: 'autonomous', op: undefined });
assert.match(m.body.op, /^mode-[0-9a-f]{24}$/);
assert.match(await text(`${P} .request-mode`), /autonomous/);
// Cancel needs a second, explicit click and says what it keeps.
await click(`${P} [data-request-cancel]`);
assert.equal((await writes()).length, 1);
assert.match(await text(`${P} .request-cancel`), /project.*(kept|stays|remains)/i);
await click(`${P} [data-request-cancel-keep]`);
assert.equal(await b.evaluate(`!!document.querySelector('${P} [data-request-cancel-confirm]')`), false);
assert.equal((await writes()).length, 1);
await click(`${P} [data-request-cancel]`);
await answer({ set: { ...ASKING, revision: 9, mode: 'autonomous', mode_revision: 2, phase: 'cancelled', question: null, wait: null } });
await click(`${P} [data-request-cancel-confirm]`);
await w(`/cancelled/.test(document.querySelector('${P} .request-phase')?.textContent)`);
const c = (await writes())[1];
assert.equal(c.path, `/api/requests/${R}/cancel`);
assert.deepEqual({ ...c.body, op: undefined }, { revision: 8, op: undefined });
assert.equal(await count(`${P} form, ${P} button:not([data-request-reload])`), 0, 'a cancelled request has no controls');
assert.match(await text(`${P} .request-project`), /factory/);
});
test('request panel: sign-out clears the panel and a late write response changes nothing', async t => {
const { b, w, set, click, answer, writes } = await setup(t, { boot: { states: { [R]: ASKING } } });
await w(`document.querySelector('${P} form.request-answer textarea')`);
await set(`${P} textarea`, 'private words');
await answer({ hold: true, set: { ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-5' }, wait: null } });
await click(`${P} form.request-answer button[type=submit]`);
await w('typeof window.__releaseR === "function"');
await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseR();true');
await w('document.body.classList.contains("signed-out")');
const reads = await b.evaluate('window.__rreads.length');
await b.evaluate('location.hash="#/chat";true');
await new Promise(r => setTimeout(r, 200));
assert.equal(await b.evaluate('window.__rreads.length'), reads, 'no read after sign-out');
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /private words/);
assert.equal((await writes()).length, 1);
// A 401 from the Console on a request read signs the page out the same way.
const out = await setup(t, { boot: { states: { [R]: ASKING } } });
await out.w(`document.querySelector('${P} form.request-answer')`);
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")');
});
+348
View File
@@ -0,0 +1,348 @@
// #797 / #1839 Task 3: the Console's request intake routes. The first half drives requestHTTP with a
// fake request, response and bus transport. The second half signs in through the fake OP on loopback
// and checks the listener's gates, as decide-sso.test.mjs does for decisions. The bus side is
// packages/bus/tests/request-console.test.mjs and the page side request-browser.test.mjs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http';
import { BusReadError } from '../src/bus.mjs';
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
import { REQUEST_REFUSALS, REQUEST_ROUTE, requestHTTP } from '../src/request-http.mjs';
import { startServer } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { consoleAccess } from '../src/access.mjs';
import { userOptionsFor } from '../src/console-bus.mjs';
import { close } from './fixture.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
const ORIGIN = 'https://console.test';
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const STATE = {
version: 1, request: 'r1', business: 'demo', revision: 5, source_message: 'm1', thread: null, session: null, owner: 'jason', pm: 'pm',
phase: 'discovery', project: 'old', chat_project: null, mode: 'autonomous', mode_revision: 1, plan_digest: 'a'.repeat(64), spec_commit: null,
snapshot: null, tasks: [], delivery: null, question: { id: 'q1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }], answer: null },
candidate: null, selection: null, route: null, wait: { kind: 'question', code: 'question', subject: 'q1', resume_phase: 'discovery', observed_revision: 5 },
};
const PROJECTS = [{ project: 'old', name: 'Old', tracker: { root: 1, project: 2 }, business: 'demo' }, { project: 'older', name: 'Older' }];
const ANSWER = { revision: 5, question: 'q1', op: 'ans-1', text: 'Web, please.\nNo installs.' };
const BODIES = {
answer: ANSWER,
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' },
};
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 } = {}) {
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const data = Buffer.from(raw ?? JSON.stringify(body));
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
}
function response() {
const r = { status: null, headers: null, text: '' };
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
r.end = (t = '') => { r.text += t; };
r.json = () => JSON.parse(r.text);
return r;
}
function setup({ read, write, verbs } = {}) {
const session = { name: 'jason', ...SUBJECT };
const reads = [], writes = [], lines = [];
const s = {
session, reads, writes, lines, current: session,
read: read ?? (async (verb, args) => (verb === 'request.projects.console' ? PROJECTS : { ...STATE, request: args.request })),
write: write ?? (async (verb, args) => ({ ...STATE, request: args.request, revision: args.revision + 1 })),
};
const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return s.read(verb, args, ctx); };
const requestWrite = async (verb, args, ctx) => { writes.push({ verb, args, ctx }); return s.write(verb, args, ctx); };
s.run = async (path, req, extra = {}) => {
const res = response();
await requestHTTP(req, res, { path, requestRead, requestWrite, session: s.session, live: () => s.current, origin: ORIGIN, log: l => lines.push(l), ...(verbs ? { verbs } : {}), ...extra });
return res;
};
s.get = (path = '/api/requests/r1', extra) => s.run(path, request({ method: 'GET' }), extra);
s.post = (action, body = BODIES[action], id = 'r1') => s.run(`/api/requests/${id}/${action}`, request({ body }));
return s;
}
const refused = (res, code) => {
assert.equal(res.status, REQUEST_REFUSALS[code][0], `${code}: ${res.text}`);
assert.deepEqual(res.json(), { error: code, message: REQUEST_REFUSALS[code][1] });
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'])
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);
});
test('the owner reads a request and its authorized projects through the read verbs with only the session subject', async () => {
const s = setup();
const state = await s.get();
assert.equal(state.status, 200, state.text);
assert.deepEqual(state.json(), STATE);
assert.equal(state.headers['cache-control'], 'no-store');
const projects = await s.get('/api/requests/r1/projects');
assert.equal(projects.status, 200, projects.text);
assert.deepEqual(projects.json(), [{ project: 'old', name: 'Old' }, { project: 'older', name: 'Older' }], 'only the picker fields cross the seam');
assert.deepEqual(s.reads, [
{ verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } },
{ verb: 'request.projects.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } },
]);
assert.equal(s.writes.length, 0, 'a read never reaches the write transport');
assert.equal(s.lines.length, 2);
assert.match(s.lines[0], / request user="jason" request="r1" action=get$/);
});
test('each write sends exactly the validated fields and the session subject, and relays the new state', async () => {
const s = setup();
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
const res = await s.post(action);
assert.equal(res.status, 200, `${action}: ${res.text}`);
assert.equal(res.json().revision, 6); assert.equal(res.json().request, 'r1');
assert.deepEqual(s.writes.at(-1), { verb: VERBS[action], args: { request: 'r1', ...BODIES[action] }, ctx: { subject: SUBJECT } }, action);
}
assert.equal(s.reads.length, 0, 'a write never reaches the read transport');
assert.doesNotMatch(s.lines.join('\n'), /No installs|Web, please/, 'answer text is never logged');
assert.match(s.lines[0], / request user="jason" request="r1" action=answer revision=5 outcome=ok$/);
});
test('the draft read fails closed while the bus has no draft verb and relays the record once it does', async () => {
assert.equal(CONSOLE_VERBS.includes('request.draft.console'), false, 'Task 4 owns this verb');
const s = setup();
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 = { request: 'r1', commit: 'c'.repeat(40), text: '# PRD\n', requirements: [{ id: 'R1', text: 'Counts down' }] };
const t = setup({ verbs: [...CONSOLE_VERBS, 'request.draft.console'], read: async () => record });
const ok = await t.get('/api/requests/r1/draft');
assert.equal(ok.status, 200, ok.text);
assert.deepEqual(ok.json(), record);
assert.deepEqual(t.reads, [{ verb: 'request.draft.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }]);
const wrong = setup({ verbs: [...CONSOLE_VERBS, 'request.draft.console'], read: async () => ({ ...record, request: 'r2' }) });
refused(await wrong.get('/api/requests/r1/draft'), 'unavailable');
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup();
refused(await s.run('/api/requests/r1/answer', request({ method: 'GET' })), 'method-not-allowed');
refused(await s.run('/api/requests/r1', request({ method: 'POST' })), 'method-not-allowed');
refused(await s.run('/api/requests/r1/projects', request({ method: 'DELETE' })), 'method-not-allowed');
// No write transport: reads still work, writes say read-only.
refused(await s.post('answer', ANSWER, 'r1').then(() => s.run('/api/requests/r1/answer', request({ body: ANSWER }), { requestWrite: null })), 'read-only');
assert.equal((await s.get()).status, 200);
// No read transport: the read says so and nothing runs.
refused(await s.get('/api/requests/r1', { requestRead: null }), 'unavailable');
// No session.
refused(await s.run('/api/requests/r1/answer', request({ body: ANSWER }), { session: null, live: () => null }), 'sign-in-required');
refused(await s.run('/api/requests/r1', request({ method: 'GET' }), { session: null, live: () => null }), 'sign-in-required');
const before = s.writes.length;
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': ['cross-site'] }, { 'sec-fetch-site': null }])
refused(await s.run('/api/requests/r1/answer', request({ body: ANSWER, headers })), 'cross-origin');
assert.equal(s.writes.length, before, 'a denied origin never reaches the bus');
// A read from another site is refused too; a read with no fetch metadata and no origin is a plain navigation.
refused(await s.run('/api/requests/r1', request({ method: 'GET', headers: { 'sec-fetch-site': ['cross-site'] } })), 'cross-origin');
refused(await s.run('/api/requests/r1', request({ method: 'GET', headers: { origin: ['https://evil.test'] } })), 'cross-origin');
});
test('a bad body, id or content type refuses before the bus', async () => {
const s = setup();
const path = '/api/requests/r1/answer';
refused(await s.run(path, request({ body: ANSWER, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
for (const bad of [
{ ...ANSWER, extra: 1 }, { ...ANSWER, business: 'other' }, { ...ANSWER, subject: SUBJECT }, { ...ANSWER, request: 'r2' },
{ ...ANSWER, revision: 0 }, { ...ANSWER, revision: '5' }, { ...ANSWER, revision: 1.5 }, { ...ANSWER, question: '' }, { ...ANSWER, question: '../x' },
{ ...ANSWER, op: '' }, { ...ANSWER, op: 'bad op' }, { ...ANSWER, text: '' }, { ...ANSWER, text: 7 }, { ...ANSWER, text: 'a\0b' }, { ...ANSWER, text: 'x'.repeat(8001) },
{ revision: 5, question: 'q1', op: 'ans-1' },
]) refused(await s.run(path, request({ body: bad })), 'invalid-request');
for (const [action, bad] of [
['attach', { ...BODIES.attach, project: '' }], ['attach', { ...BODIES.attach, project: '../x' }], ['attach', { ...BODIES.attach, text: 'x' }],
['mode', { ...BODIES.mode, mode: 'auto' }], ['mode', { ...BODIES.mode, mode_revision: 0 }], ['mode', { ...BODIES.mode, mode_revision: '1' }], ['mode', { revision: 5, mode: 'guided', op: 'm' }],
['cancel', { ...BODIES.cancel, project: 'old' }], ['cancel', { revision: 5 }],
]) refused(await s.post(action, bad), 'invalid-request');
refused(await s.run(path, request({ raw: JSON.stringify({ ...ANSWER, text: 'y'.repeat(70000) }) })), 'request-too-large');
refused(await s.run('/api/requests/r1/cancel', request({ raw: JSON.stringify({ ...BODIES.cancel, op: 'y'.repeat(5000) }) })), 'request-too-large');
refused(await s.run('/api/requests/..%2Fx/answer', request({ body: ANSWER })), 'request-not-found');
refused(await s.run('/api/requests/%E0%A4%A/answer', request({ body: ANSWER })), 'request-not-found');
refused(await s.get('/api/requests/..%2Fx'), 'request-not-found');
assert.equal(s.writes.length + s.reads.length, 0);
});
test('bus refusals map by code, and an unknown code is unavailable at a read and outcome-unknown at a write, never retried', async () => {
const cases = [
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'], ['unauthenticated', 'human-required'],
['peercred-required', 'peercred-required'], ['request-not-found', 'request-not-found'], ['request-revision-conflict', 'request-revision-conflict'],
['request-phase-refused', 'request-phase-refused'], ['request-mode-stale', 'request-mode-stale'], ['request-mode-unchanged', 'request-mode-unchanged'],
['request-question-closed', 'request-question-closed'], ['request-question-mismatch', 'request-question-mismatch'],
['request-project-refused', 'request-project-refused'], ['request-operation-conflict', 'request-operation-conflict'], ['invalid-request', 'invalid-request'],
];
for (const [bus, code] of cases) {
const w = setup({ write: async () => { throw new BusReadError(bus); } });
refused(await w.post('answer'), code);
assert.equal(w.writes.length, 1, `${bus} is not retried`);
const r = setup({ read: async () => { throw new BusReadError(bus); } });
refused(await r.get(), code);
assert.equal(r.reads.length, 1);
}
for (const err of [new BusReadError('weird'), new BusReadError('outcome-unknown'), new Error('boom'), new BusReadError('invalid-response')]) {
const w = setup({ write: async () => { throw err; } });
refused(await w.post('mode'), 'outcome-unknown');
assert.equal(w.writes.length, 1);
const r = setup({ read: async () => { throw err; } });
refused(await r.get(), 'unavailable');
refused(await r.get('/api/requests/r1/projects'), 'unavailable');
}
});
test('a reply that is not the state of this request is outcome-unknown, never relayed', async () => {
for (const reply of [null, [], 'ok', { ...STATE, request: 'r2' }, { ...STATE, version: 2 }, { ...STATE, revision: 0 }, { ...STATE, revision: '5' }, { ...STATE, phase: 7 }]) {
const w = setup({ write: async () => reply });
refused(await w.post('cancel'), 'outcome-unknown');
const r = setup({ read: async () => reply });
refused(await r.get(), 'unavailable');
}
for (const reply of [null, {}, [{ name: 'Old' }], [{ project: 'old' }], [{ project: 'old', name: 7 }], 'old']) {
const r = setup({ read: async () => reply });
refused(await r.get('/api/requests/r1/projects'), 'unavailable');
}
});
test('a session that ends between the gate and the spawn refuses before the bus', async () => {
const s = setup();
s.current = null;
refused(await s.post('answer'), 'sign-in-required');
refused(await s.get(), 'sign-in-required');
s.current = { ...s.session };
refused(await s.post('answer'), 'sign-in-required');
assert.equal(s.writes.length + s.reads.length, 0);
});
// The listener. decide-sso.test.mjs's harness: the fake OP, createSso over it, and the human listener.
const HOST = 'console.example.test', LISTEN = `https://${HOST}`;
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = httpRequest({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: LISTEN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
async function listener(t, server = {}, access = null) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
const config = { publicOrigin: LISTEN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
const lines = [];
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
t.after(() => sso.close());
const reads = [], writes = [];
const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return { ...STATE, request: args.request }; };
const requestWrite = async (verb, args, ctx) => { writes.push({ verb, args, ctx }); return { ...STATE, request: args.request, revision: args.revision + 1 }; };
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), requestRead, requestWrite, log: l => lines.push(l), ...userOptions, ...server });
t.after(() => close(web));
const port = web.address().port;
const signIn = async (sub = undefined) => {
const start = await call(port, { path: '/auth/login' });
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
const g = op.grant(iss, start.headers.location, { sub });
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
};
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
return { port, reads, writes, lines, signIn, post, get: (cookie, path, headers = {}) => call(port, { path, headers: { ...(cookie ? { cookie } : {}), ...headers } }) };
}
test('a signed-in owner reads and writes through the listener with the session subject, and the config says so', async t => {
const env = await listener(t);
const cookie = await env.signIn();
const config = await env.get(cookie, '/api/config');
assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, true);
const read = await env.get(cookie, '/api/requests/r1');
assert.equal(read.status, 200, JSON.stringify(read.body));
assert.equal(read.body.request, 'r1');
const write = await env.post(cookie, '/api/requests/r1/answer', ANSWER);
assert.equal(write.status, 200, JSON.stringify(write.body));
const subject = { provider: 'a', iss: env.reads[0].ctx.subject.iss, sub: 'user-a' };
assert.deepEqual(env.reads[0], { verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject } });
assert.deepEqual(env.writes[0], { verb: 'request.answer.console', args: { request: 'r1', ...ANSWER }, ctx: { subject } });
assert.equal(env.lines.some(l => l.includes('No installs') || l.includes(cookie.split('=')[1])), false);
});
test('without a write transport the listener reads but every intake write is read-only, and the config says so', async t => {
const env = await listener(t, { requestWrite: null });
const cookie = await env.signIn();
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']) {
const r = await env.post(cookie, `/api/requests/r1/${action}`, BODIES[action]);
assert.deepEqual([r.status, r.body.error], [403, 'read-only'], action);
}
assert.equal(env.writes.length, 0);
const none = await listener(t, { requestRead: null, requestWrite: null });
const c2 = await none.signIn();
const cfg = await none.get(c2, '/api/config');
assert.equal('requests' in cfg.body && cfg.body.requests, false);
assert.equal(cfg.body.requestWrites ?? false, false);
assert.equal((await none.get(c2, '/api/requests/r1')).body.error, 'unavailable');
});
test('a signed-out caller, a denied origin and a granted user all stop before the transport', async t => {
const access = {
accessVersion: 1, revision: 1,
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }],
};
const env = await listener(t, {}, access);
const signedOut = await env.post(null, '/api/requests/r1/answer', ANSWER);
assert.deepEqual([signedOut.status, signedOut.body.error], [401, 'sign-in-required']);
assert.equal((await env.get(null, '/api/requests/r1')).status, 401);
const owner = await env.signIn();
for (const headers of [{ origin: 'https://evil.test' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }]) {
const sent = { ...writeHeaders(owner), ...headers };
for (const k of Object.keys(sent)) if (sent[k] === null) delete sent[k];
const r = await call(env.port, { method: 'POST', path: '/api/requests/r1/answer', headers: sent, body: JSON.stringify(ANSWER) });
assert.equal(r.status, 403); assert.ok(['cross-origin'].includes(r.body.error) || r.body.error === 'cross-origin request refused', JSON.stringify(r.body));
}
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']) {
const r = await env.post(alice, `/api/requests/r1/${action}`, BODIES[action]);
assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], action);
}
for (const path of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft']) {
const r = await env.get(alice, path);
assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], path);
}
assert.equal(env.reads.length + env.writes.length, 0);
});
test('startServer refuses request transports without sso, without send, or on the tester listener', async () => {
const fn = async () => ({});
await assert.rejects(startServer({ port: 0, requestRead: fn }), /Console requests need --sso/);
await assert.rejects(startServer({ port: 0, requestWrite: fn, send: async () => ({}) }), /Console requests need --sso/);
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, requestRead: fn }), /Console requests need --sso/);
await assert.rejects(startServer({ port: 0, requestRead: 'x' }), /Console requests need --sso/);
});
test('the request verbs are the console wrapper\'s, and the Console names no owner CLI verb for them', async () => {
const { readFileSync } = await import('node:fs');
for (const verb of ['request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.mode.console', 'request.cancel.console'])
assert.ok(CONSOLE_VERBS.includes(verb), verb);
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
assert.doesNotMatch(cli, /['"]request\.(get|projects|answer|attach|mode|cancel)(?!\.console)['"]/);
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');
});