Compare commits

...
Author SHA1 Message Date
jason.woltje c52218c659 fix(console): check the owner-to-PM edge on every intake control, refuse request URLs without SSO, bound the browser waits (#1839)
Review r1 of lifecycle Task 3.

C1: the four owner controls (answer, attach, mode, cancel) inserted and routed a
human-to-PM message without the messaging policy check message.send makes. The
shared writer now reads the current human-to-PM edge inside the control's
transaction, before it inserts or routes anything, and refuses with
message-sender-refused. The refusal rolls back the journal row, the human.input
event, the message and a withdrawn selection decision. The cancel withdrawal
checks the same edge. Regression tests close the edge after the question and
cover all four controls, then reopen it and prove the same call lands once.

I1: serve.mjs read sso.publicOrigin for a request URL when the Console ran
without --sso (or in preview mode), which ended the process on an unauthenticated
GET /api/requests/r1. A request route now answers through requestHTTP with no
transports and no SSO fields: unavailable on a read, read-only on a write, 405
for the wrong method. Listener tests cover GET and POST without SSO and in
preview mode, named and loopback, and the process keeps serving.

M1: the browser test waiter allowed about 3 s and reload() could run out its loop
without failing. Waits are now deadline-bound at 15 s and name the expression,
and reload() waits for the new document's own ready flag and the mounted chat,
and throws if it never gets there. The no-repost and stored-answer assertions of
the lost-answer case are unchanged.

Migration 3p-3q is unchanged.
2026-11-04 10:58:48 -06:00
jason.woltje 7fd0c35628 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.
2026-11-03 22:05:53 -06:00
34 changed files with 2208 additions and 69 deletions

No files matched your search

+2
View File
@@ -6007,3 +6007,5 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-04T02:45Z | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | One fix round on c55849806 for review findings C1, C2, I1, I2, I3, I4; Minor items, Tasks 3-8, roles/*.json, ROUTED and all migrations untouched (no schema change). C1: seeding no longer follows a committed .mosaic link. Linked ancestors and non-regular metadata are refused in the source and in the clone, and the seed and the marker temp are written with O_EXCL|O_NOFOLLOW and renamed, so a planted link is never followed. C2: the clone goes into an exclusively created code.tmp backed by a code.tmp.claim receipt; an unclaimed or foreign-content code.tmp is kept untouched with bootstrap-uncertain, a claimed clone left by a kill is adopted exactly once, and no deterministic path is removed recursively. I1: the #800 freshness check runs before every effect including recovery effects; a mismatch appends setup wait request-plan-stale and does nothing further, evidence kept. I2: managedState classifies by layout (entry root and repo match the managed paths) and reads readiness from the marker and the committed activate receipt, so a missing bootstrap.json no longer admits an unfinished product; the launcher and trackerFor already call managedState and needed no change. I3: new packages/bus/src/request-host.mjs wires preparePlans and admitPlan in the real host from the organization document and revision, the booted tracker config and the staff the organization names; a missing tracker root or staff is a named setup wait (request-tracker-unavailable, request-staff-unavailable, request-organization-unavailable, request-organization-changed) and creates nothing; process.mjs now forwards trackers to startBroker. A normal boot with a tracker root and named staff reaches a bootstrap selection and bootstraps the project. I4: the Task 1 existing-project comparison was reversed and is fixed in localPlan with regressions in request-intake. RED/GREEN: each regression was failing on c55849806 and passes now; the regression set was mutation-checked by reverting each guard, with two exceptions recorded honestly: the executor-level metadataProblem mutant (c1-exec) still passes because git status is a second guard, and the i3 admitPlan unit test was not mutation-checked. Corrections: the committed project-file link test first failed at boot because the config loader refuses the link (test now commits the link after boot); the c2-claim mutant first survived because clone verification is a second guard (two unclaimed-code.tmp tests added); boot recovery rewrote a complete marker for a deleted reserved one, fixed by requiring the marker to prove ownership before every post-reserve effect; the i2 assertion on admission revision null was dropped because that Minor item is out of scope. First full bus run had one failure, model-final-completion 656 FIFO niceness (startNice 16 !== 19), unrelated to this change; the file passed 21/21 three times alone and the full rerun was 1477/1477. GREEN: brief trio 79/79, kept-green trio 49/49, bus 1477/1477 (was 1456), business 305/305 (was 304), project+seat 267/267, stack-release 225/225, zero skips. Limits: the selected tracker board is unverified until the first task is created; after a post-effect stale wait nothing resolves automatically, the request waits and each resume re-checks; a kill mid-clone leaves code.tmp kept and uncertain, never deleted; the prepareExisting/listProjects wiring is out of scope; admission revision stays null. Logs /mnt/storage/scratch/tmp/fix797/. Not executed on any running instance; local commit only, no push/merge/PR/deployment.
2026-11-04T02:51:19Z | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 / Gitea #1839 lifecycle Task 2 landing | Task 2 c55849806 (Sonnet 5.5 xhigh): exact-add organization writer, journaled bootstrap steps, 3o->3p append-only migration, receipt-backed runtime admission. Review R1 (GPT-6.1 Sol): NEEDS FIXES, C1 seed write through a committed .mosaic link, C2 recursive code.tmp delete, I1 #800 freshness only at reservation, I2 missing marker admitted, I3 no host preparation/admission wiring, I4 Task 1 existing-project root/repo comparison reversed. Fix round 883003041 (Sonnet 5.5 xhigh): O_EXCL|O_NOFOLLOW publish, claimed code.tmp, per-effect fresh(), layout-based managedState, request-host.mjs wiring, corrected localPlan. Gates on 883003041: focused 79/79, kept-green 49/49, bus 1477/1477 (first run had one model-final-completion startNice flake, reviewer found no causal path), business 305/305, project+seat 267/267, stack-release 225/225, zero skips (/mnt/storage/scratch/tmp/fix797). Review R2: C1 C2 I3 I4 resolved; I1 (org write during intent step Git reads still lets a stale intent write), I2 (failed final marker rename or later marker removal leaves admission ready) and new N1 (preexisting <dataRoot>/working ancestor link sends the working repo outside dataRoot) open. Lead decision: land under the MVP one-round rule; all three need write access to dataRoot, a concurrent organization edit or an I/O fault, and none is reachable by a sandboxed worker. They form one hardening follow-up that must land before bootstrap is exercised on .53. Minors are tracker #801.
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
@@ -1997,3 +1997,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | C1 link-confined seed and marker writes, C2 receipt-backed code.tmp kept as bootstrap-uncertain when unproven, I1 freshness recheck before every effect, I2 layout-based managed classification, I3 host preparePlans/admitPlan wired (named setup waits), I4 existing-project comparison. Brief 79/79, kept-green 49/49, bus 1477/1477, business 305/305, project+seat 267/267, stack-release 225/225, zero skips; no schema change; local commit only, no push.
2026-11-04 | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 lifecycle Task 2 review, fix round and landing | R1 needs fixes, fix 883003041, R2 left I1/I2/N1 residuals as a pre-.53 hardening follow-up; landed on refactor
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,
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','cancelled')),
kind TEXT NOT NULL CHECK (kind IN ('opened','selection.proposed','selection.staged','selection.confirmed','setup.wait','bootstrap.stale','bootstrap.completed','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'),
+80 -7
View File
@@ -156,7 +156,16 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
// 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.
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console', 'request.get.console',
'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.mode.console', 'request.cancel.console']);
// What an owner's console cap may ask of the request lifecycle (a read, and the four owner controls).
const INTAKE_CONSOLE = Object.freeze(['get', 'answer', 'attach', 'mode', 'cancel']);
const REQUEST_VERBS = Object.freeze({ 'request.open': 'open', 'request.get': 'get', 'request.projects': 'projects', 'request.propose': 'propose',
'request.question': 'question', 'request.get.console': 'get', 'request.projects.console': 'projects', 'request.answer.console': 'answer',
'request.attach.console': 'attach', 'request.mode.console': 'mode', 'request.cancel.console': 'cancel' });
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
@@ -583,8 +592,23 @@ export class Broker {
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 });
},
// Task 3 (#1839): the owner's Console controls, 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.`),
recordMode: (identity, state, a) => this.#intakeInput(identity, state,
{ kind: 'mode', request: state.request, mode: a.mode, mode_revision: a.mode_revision, op: a.op },
`The owner changed request ${state.request} to ${a.mode} mode (mode revision ${a.mode_revision}). An approval or dispatch bound to the earlier mode no longer applies.`),
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','mode','cancel','resume','recover'].every(k => typeof this.#requests[k] === 'function'))
throw new TypeError('invalid request lifecycle');
}
}
@@ -592,18 +616,57 @@ export class Broker {
if (!s || s.business !== state.business) fail('request-access-refused');
if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused');
if (s.user || s.console) fail('human-required');
if (s.user) fail('human-required');
// Task 3 (#1839): only the configured owner's Console cap reaches the owner's intake controls, and they reach nothing else.
// The CLI human guard in #human is not weakened: a console cap never passes it.
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');
return;
}
if (s.human) {
this.#human(s);
if (s.human !== state.owner || s.human !== this.#business(s.business).human) fail('request-access-refused');
if (operation === 'propose') fail('agent-required');
if (!['open', 'get'].includes(operation)) fail('agent-required');
} else {
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');
// No separate authority: intake preparation consumes the PM's existing message.send grant.
if (operation !== 'get') this.#checkAuthority(s, 'message.send', { target: state.pm });
}
}
// 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) {
this.#intakeEdge(s, state);
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 owner's controls obey the same messaging policy as an ordinary message.send. The edge is read from the business
// document as it is now, inside the control's transaction, so a policy reloaded after the PM asked its question
// refuses with the code message.send gives and rolls back whatever the control had written.
#intakeEdge(s, state) {
if (!s.console || s.human !== state.owner || s.human !== this.#business(s.business).human) fail('human-required');
this.#checkMessaging(s.business, 'human', state.pm);
}
// The cancel of a request whose selection decision is still open closes that decision as withdrawn by the owner.
#withdrawIntake(s, state, decision) {
this.#intakeEdge(s, state);
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) {
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);
@@ -4976,11 +5039,11 @@ export class Broker {
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
}
this.#processingGate(s, request.verb);
if (['request.open','request.get','request.projects','request.propose'].includes(request.verb)) {
if (Object.hasOwn(REQUEST_VERBS, request.verb)) {
if (s.reader) fail('read-only');
if (s.tester) fail('tester-refused');
if (!this.#requests) fail('request-not-configured');
const method = request.verb.slice('request.'.length);
const method = REQUEST_VERBS[request.verb];
return Promise.resolve().then(() => this.#requests[method](s, structuredClone(args)))
.then(result => { this.#secretCheck(result); return result; })
.catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
@@ -7119,7 +7182,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}`,
session,
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.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
+51
View File
@@ -0,0 +1,51 @@
-- #797 / #1839 Task 3: the request journal learns four 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), attach.selected (the owner's pick of an existing project, before the PM proposes it) and
-- mode.changed (an explicit owner mode change). 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_3p;
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','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_3p 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_3p';
DROP TABLE request_events_3p;
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;
+112 -3
View File
@@ -1,8 +1,9 @@
// 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, mode change and cancellation.
// Later executors must consume these exact immutable bindings, never approvalChoice.
import { lstatSync } from 'node:fs';
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, MODES, QUESTION_PHASES, MODE_PHASES, PICKER_CODES } from './request-store.mjs';
export function createRequestLifecycle({ journal, services }) {
if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable');
@@ -116,6 +117,8 @@ export function createRequestLifecycle({ journal, services }) {
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.
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');
let candidate = null;
if (existing) {
@@ -231,6 +234,112 @@ export function createRequestLifecycle({ journal, services }) {
return runBootstrap(confirmed);
});
}
return Object.freeze({ open, get, projects, propose, resume,
// 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);
});
});
}
// An explicit owner control with both expected revisions. Only the mode and its revision move, so a later approval
// or dispatch that bound the old mode_revision no longer matches at its next action.
async function mode(identity, args) {
exact(args, ['request','revision','mode_revision','mode','op']);
if (!id(args.request) || !positive(args.revision) || !positive(args.mode_revision) || !MODES.includes(args.mode) || !id(args.op)) refuse('invalid-request');
return serial(identity.business, args.request, () => {
const state = stateOf(identity.business, args.request); authorize(identity, state, 'mode');
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.mode === null || !MODE_PHASES.includes(state.phase)) refuse('request-phase-refused');
if (state.mode_revision !== args.mode_revision) refuse('request-mode-stale');
if (state.mode === args.mode) refuse('request-mode-unchanged');
return journal.transaction(tx => {
need('recordMode')(identity, structuredClone(state), { mode: args.mode, mode_revision: state.mode_revision + 1, op: args.op });
return append(state, 'mode.changed', { op: args.op, proposal_digest, mode: args.mode, mode_revision: args.mode_revision }, 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, question, answer, attach, mode, cancel, resume,
recover: async () => { const states = []; for (const { business, request } of journal.listRequests()) states.push(await resume(business, request)); return states; } });
}
+49 -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);
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 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') {
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,14 @@ 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');
const equal = (a, b) => digest(a) === digest(b);
const nullableId = x => x === null || id(x);
export const MODES = ['autonomous','guided'];
// A question belongs to the PM's discovery, PRD review and planning work; the owner's mode can change
// once a project mode exists and until delivery (not while bootstrap runs a plan bound to its mode).
export const QUESTION_PHASES = ['discovery','prd-review','planning'];
export const MODE_PHASES = ['discovery','prd-review','planning','implementation','review','delivery'];
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) {
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']);
@@ -107,9 +115,40 @@ 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);
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 };
} 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 === 'mode.changed') {
// Only the mode and its revision move. plan_digest and the bound plans stay as the evidence of what was confirmed.
exact(b, ['op','proposal_digest','mode','mode_revision'], [], code);
if (!id(b.op) || !hash(b.proposal_digest) || !MODES.includes(b.mode) || !positive(b.mode_revision)) refuse(code);
if (s.mode === null || !MODE_PHASES.includes(s.phase) || b.mode_revision !== s.mode_revision || b.mode === s.mode) refuse(code);
s.mode = b.mode; s.mode_revision += 1;
} else if (e.kind === 'cancelled') {
exact(b, ['decision'], [], code);
if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision) refuse(code);
// `decision` is the open selection decision the cancellation closes, or null when none is open. A project or
// 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;
} else refuse(code);
return s;
@@ -150,6 +189,13 @@ export function createRequestStore(store) {
if (!d || d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.blocking !== 0 || d.task_ref !== null || d.requirement_ref !== null ||
d.raised_by_role !== state.pm || store.get("SELECT 1 FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired')", d.id)) refuse('invalid-request-event');
}
// 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
// that was not activated, or retreat from one whose mapping already landed.
if (e.kind === 'bootstrap.completed' || e.kind === 'bootstrap.stale') {
+8 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'ae56fbdcb5ea9df3a15a47c7978f5c22516cf1c85dd9a1c9885a63bf7f80d252';
export const SCHEMA_VERSION = '3p';
export const SCHEMA_SHA256 = 'b2b1275010384b7f6ebd0ae068c6596930fb271d73ca450a784cf435d6607a44';
export const SCHEMA_VERSION = '3q';
// 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 LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -93,6 +93,12 @@ const migrations = [{
from: '3o',
to: '3p',
sql: readFileSync(new URL('./migrations/3o-3p.sql', import.meta.url), 'utf8'),
}, {
// #797 / #1839 Task 3: the request journal's question.asked, question.answered, attach.selected and
// mode.changed kinds, for the Console intake controls. request_events is rebuilt with the wider kind list.
from: '3p',
to: '3q',
sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -90,6 +90,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.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/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -97,7 +98,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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
// 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(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -147,7 +148,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);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -159,14 +160,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
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.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
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');
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
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/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
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');
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);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
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);
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');
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}';`)
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3q';")
.replace('}];\nconst timedTables', `}, { from: '3p', to: '3q', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3r';")
.replace('}];\nconst timedTables', `}, { from: '3q', to: '3r', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
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'])
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'])
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 future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
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 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 migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3p ready/);
assert.match(migrated.stdout, /bus schema 3q ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+6 -2
View File
@@ -86,8 +86,12 @@ function setup(t) {
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
}
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
// #797 / #1839 Task 3 widens the list by the six request-intake verbs, and only those: the decision
// pair stays first and unchanged, and no unsuffixed verb is ever a console verb.
test('N4: the console verbs are the decision pair and the six request-intake console verbs, and decision.resolve is not one', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.mode.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
});
+5 -4
View File
@@ -80,6 +80,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.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/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -101,7 +102,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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -127,7 +128,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(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -138,14 +139,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
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.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+5 -4
View File
@@ -83,6 +83,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.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/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -106,7 +107,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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(migrated3l(rows(path)), before);
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)));
@@ -131,7 +132,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(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -142,14 +143,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
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.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
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/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
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.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+384
View File
@@ -0,0 +1,384 @@
// #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' };
const WRITES = ['request.answer.console', 'request.attach.console', 'request.mode.console', 'request.cancel.console'];
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 exactly the decision pair plus the six intake verbs', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'request.get.console', 'request.projects.console',
'request.answer.console', 'request.attach.console', 'request.mode.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);
});
test('a mode change takes the expected revisions, bumps the mode revision and leaves the bound plan as evidence', async t => {
const x = await atDiscovery(t), request = x.source.request, s = x.state;
assert.equal(s.mode, 'autonomous'); assert.equal(s.mode_revision, 1);
const args = { request, revision: s.revision, mode_revision: 1, mode: 'guided', op: 'mode-1' };
for (const bad of [{ mode: 'auto' }, { mode_revision: 0 }, { mode_revision: '1' }, { revision: 0 }]) await assert.rejects(async () => x.call('console', 'request.mode.console', { ...args, op: 'm-bad', ...bad }), /invalid-request/);
await assert.rejects(async () => x.call('console', 'request.mode.console', { ...args, revision: s.revision - 1 }), /request-revision-conflict/);
await assert.rejects(async () => x.call('console', 'request.mode.console', { ...args, mode_revision: 2 }), /request-mode-stale/);
await assert.rejects(async () => x.call('console', 'request.mode.console', { ...args, mode: 'autonomous' }), /request-mode-unchanged/);
const changed = await x.call('console', 'request.mode.console', args);
assert.equal(changed.mode, 'guided'); assert.equal(changed.mode_revision, 2); assert.equal(changed.revision, s.revision + 1);
assert.equal(changed.plan_digest, s.plan_digest, 'the confirmed plan binding stays as evidence');
assert.deepEqual(await x.call('console', 'request.mode.console', args), changed, 'the same op is the recorded change');
assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='mode.changed'"), 1);
const ev = x.store.get("SELECT * FROM events WHERE kind='human.input' AND json_extract(body,'$.kind')='mode'");
assert.deepEqual(JSON.parse(ev.body), { kind: 'mode', request, mode: 'guided', mode_revision: 2, op: 'mode-1', via: 'console', subject: SUBJECT, message: ev.subject });
// The next change must name the new mode revision; the old one is stale.
await assert.rejects(async () => x.call('console', 'request.mode.console', { ...args, op: 'mode-2', revision: changed.revision, mode: 'autonomous' }), /request-mode-stale/);
const back = await x.call('console', 'request.mode.console', { ...args, op: 'mode-2', revision: changed.revision, mode_revision: 2, mode: 'autonomous' });
assert.equal(back.mode, 'autonomous'); assert.equal(back.mode_revision, 3);
});
test('a mode change is refused before a project mode exists and after the request ends', async t => {
const sel = await atSelection(t);
await assert.rejects(async () => sel.call('console', 'request.mode.console', { request: sel.source.request, revision: sel.state.revision, mode_revision: 1, mode: 'guided', op: 'm1' }), /request-phase-refused/);
const x = await atDiscovery(t);
const done = await x.call('console', 'request.cancel.console', { request: x.source.request, revision: x.state.revision, op: 'c1' });
await assert.rejects(async () => x.call('console', 'request.mode.console', { request: x.source.request, revision: done.revision, mode_revision: 1, mode: 'guided', op: 'm2' }), /request-phase-refused/);
});
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);
});
// Review r1 C1: an owner control is a human-to-PM message, so it obeys the messaging policy message.send obeys. The
// policy is read when the control runs, not when the PM asked, and a refusal leaves no evidence behind.
const evidence = x => ({
journal: n(x, 'SELECT count(*) n FROM request_events'),
inputs: n(x, "SELECT count(*) n FROM events WHERE kind='human.input'"),
messages: n(x, 'SELECT count(*) n FROM messages'),
consoleMessages: n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"),
decisionEvents: n(x, 'SELECT count(*) n FROM decision_events'),
});
async function controlUnderRefusedEdge(x, verb, args) {
const state = await get(x), policy = structuredClone(x.business);
policy.definitions = { pm: { messaging: { sendTo: ['human', 'pm'], receiveFrom: ['pm'] } } };
x.b.reloadBusiness('demo', policy);
await assert.rejects(async () => x.call('human', 'message.send', { to: 'pm', body: 'direct' }), /message-sender-refused/, 'message.send refuses this edge');
const before = evidence(x);
await assert.rejects(async () => x.call('console', verb, args), /message-sender-refused/, verb);
assert.deepEqual(evidence(x), before, 'no journal row, human.input, message or decision close survives the refusal');
assert.deepEqual(await get(x), state, 'the request is exactly where it was');
// The refusal was the policy and nothing else: with the edge back, the very same call succeeds once.
x.b.reloadBusiness('demo', x.business);
const done = await x.call('console', verb, args);
assert.equal(n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"), before.consoleMessages + 1, 'one message, from the retry only');
assert.equal(done.revision, state.revision + 1);
return done;
}
test('an answer is refused when the owner-to-PM edge closed after the question, and leaves no evidence', async t => {
const x = await atDiscovery(t), asked = await ask(x, x.state);
const done = await controlUnderRefusedEdge(x, 'request.answer.console',
{ request: x.source.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'the web' });
assert.ok(done.question.answer);
});
test('an attach pick is refused when the owner-to-PM edge closed, and leaves no evidence', async t => {
const x = await atPicker(t);
const done = await controlUnderRefusedEdge(x, 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
assert.equal(done.revision, x.state.revision + 1);
});
test('a mode change is refused when the owner-to-PM edge closed, and leaves no evidence', async t => {
const x = await atDiscovery(t);
const done = await controlUnderRefusedEdge(x, 'request.mode.console',
{ request: x.source.request, revision: x.state.revision, mode_revision: x.state.mode_revision, mode: 'guided', op: 'mode-1' });
assert.equal(done.mode, 'guided');
});
test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn decision and no cancellation', async t => {
const open = await atSelection(t), decision = open.state.selection.decision;
const done = await controlUnderRefusedEdge(open, 'request.cancel.console', { request: open.source.request, revision: open.state.revision, op: 'cancel-1' });
assert.equal(done.phase, 'cancelled');
assert.equal(n(open, "SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", decision), 1, 'withdrawn once, by the retry');
const x = await atDiscovery(t), asked = await ask(x, x.state);
assert.equal((await controlUnderRefusedEdge(x, 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-2' })).phase, 'cancelled');
});
test('3p to 3q widens only the event kinds and keeps rows, seq and guards', t => {
assert.equal(SCHEMA_VERSION, '3q');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3q-'));
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');
const ddl3p = current.replace("'bootstrap.completed','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'bootstrap.completed','cancelled'");
assert.notEqual(ddl3p, current, 'the fixture is the narrower schema');
const db = new DatabaseSync(path); db.exec(ddl3p);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3p');
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');
assert.throws(() => add(3, 'question.asked'), /CHECK constraint failed/, 'the 3p 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 3q 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');
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', 'mode.changed'].entries()) next(3 + i, kind);
assert.equal(store.get('SELECT max(seq) n FROM request_events').n, 6, 'the sequence carries on from the old table');
assert.throws(() => next(6, 'cancelled'), /append-only/, 'a replayed revision is refused');
assert.throws(() => next(9, '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',7,'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 => {
const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
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.
const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','cancelled'", "'setup.wait','cancelled'");
// Tasks 2 and 3 (3o→3p, 3p→3q) 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','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');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
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);
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' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3p ready\n'); assert.equal(SCHEMA_VERSION, '3p');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q ready\n'); assert.equal(SCHEMA_VERSION, '3q');
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);
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 });
const path = join(root, 'bus/bus.sqlite');
// 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','cancelled'", "'setup.wait','cancelled'");
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
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);
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 3p ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3q 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');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
const after = tables(path);
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);
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3m';")
.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');
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);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
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.
+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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
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 edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3k';")
.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');
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);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
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.
+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/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
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.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 pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3p';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3d';")
.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');
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/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
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.
@@ -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 migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3p ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3q 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);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
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_VERSION = '3p';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3q';", "export const SCHEMA_VERSION = '3l';")
.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');
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);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3p');
assert.equal(version(path), '3q');
assert.deepEqual(master(path), was);
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.
+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/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3q');
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 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.
The tester listener has no decision routes. Decisions routed to agent roles
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
"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,
+7 -2
View File
@@ -68,7 +68,7 @@ try {
// the system config, Business can't resolve authority and Settings has
// 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.
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;
try {
system = loadSystem();
@@ -79,6 +79,11 @@ try {
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
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'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -107,7 +112,7 @@ try {
}
let server;
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; }
let testerServer = null;
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-tags, #chat-room-receipt { overflow-wrap: anywhere; }
#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() {
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
window.MosaicRequests?.forget();
if (current) current.container.replaceChildren();
current = null;
}
@@ -185,9 +186,23 @@
reverts.set(commit, { pending: false, receipt });
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) {
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;
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>';
c.container.innerHTML = html + '</div>';
window.MosaicRequests?.mount(c.container);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const log = c.container.querySelector('.chat-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="/editor.css"><script src="/editor.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>
<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>
+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);
}
+21 -5
View File
@@ -13,6 +13,7 @@ import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-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 function isLoopback(host) {
@@ -32,7 +33,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy', ['proxy.html', 'text/html; 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']]),
['/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.
@@ -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
// 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.
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 (host === 'localhost') host = '127.0.0.1';
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');
// 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');
// #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 upstream = boardURL(board);
// `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).
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.
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}`))
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;
if (sso !== null && named) {
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/')) });
if (!session && path !== '/healthz') {
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' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -774,6 +779,17 @@ 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;
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');
// Without SSO there is no owner session to act as, and preview mode has none either. Refuse here, before any
// SSO field is read: requestHTTP answers a missing transport itself and touches no session, live check or origin.
if (!(named && sso !== null))
return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log });
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin, log });
}
// 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))
return json(res, 403, { error: 'read-only', sent: false });
@@ -907,7 +923,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).
// `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.
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 === '/favicon.ico') { res.writeHead(204); return res.end(); }
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');
await b.evaluate('document.querySelector("#hide-offline").click()');
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.
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']);
// 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', '/requests.js']);
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('!!window.injected'), false);
@@ -0,0 +1,385 @@
// #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 = {};
window.__stubReady = true;
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 });
};
})();`;
// Review r1 M1: one bound for every wait on the page, sized for the full suite where Chromium shares the host with
// the other files. It is far above a quiet run and below the 25 s CDP limit, so a page that never gets there fails
// here, by name, instead of passing a silent loop.
const WAIT_MS = 15000;
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, ms = WAIT_MS) => b.evaluate(`(async()=>{const end=performance.now()+${ms};for(;;){if(${expression})return;if(performance.now()>end)throw new Error('timed out after ${ms} ms waiting for: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,30));}})()`);
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));
// The explicit signal is the new document's own: the old window carries __old, and only a fresh document has the stub's
// __stubReady with the load complete. Then the chat has mounted. If that never happens, this throws.
const reload = async () => {
await b.evaluate('window.__old=true,true');
await b.call('Page.reload', { ignoreCache: true });
const end = Date.now() + WAIT_MS;
for (;;) {
try { if (await b.evaluate('!window.__old && window.__stubReady === true && document.readyState==="complete"')) break; } catch { /* the old page is going away */ }
if (Date.now() > end) throw new Error(`the reloaded page was not ready after ${WAIT_MS} ms`);
await sleep(50);
}
await w('document.querySelector(".chat-conversation")');
};
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")');
});
+406
View File
@@ -0,0 +1,406 @@
// #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);
// A listener that stops answering fails the test here, fast and by name, instead of hanging the file.
req.setTimeout(10000, () => req.destroy(new Error(`no response from ${method} ${path}`)));
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);
});
// Review r1 I1: with no SSO there is no owner session, so a request URL is refused where the other intake refusals
// are, and never reaches an SSO field. The first form of this bug was an uncaught TypeError that ended the process.
const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => {
const seen = [];
for (const path of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft']) {
const r = await call(port, { path, host, headers: extra });
seen.push([path, r.status, r.body.error]);
}
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host,
headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) });
seen.push([action, r.status, r.body.error]);
}
const wrong = [await call(port, { method: 'POST', path: '/api/requests/r1', host, headers: extra, body: '{}' }),
await call(port, { path: '/api/requests/r1/answer', host, headers: extra })];
seen.push(['wrong-method', ...wrong.map(r => r.status)]);
return seen;
};
const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'],
['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['wrong-method', 405, 405]];
test('without --sso a request URL is refused by the listener and the process keeps serving', async t => {
const crashes = [];
const onCrash = err => crashes.push(err);
process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(web));
const port = web.address().port, host = `127.0.0.1:${port}`;
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host), EXPECTED_WITHOUT_OWNER);
// The same-origin form a page would send changes nothing: there is still no owner to act as.
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host, { origin: `http://${host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER);
const cross = await call(port, { path: '/api/requests/r1', host, headers: { origin: 'https://evil.test' } });
assert.deepEqual([cross.status, cross.body.error], [403, 'cross-origin']);
assert.equal((await call(port, { path: '/api/config', host })).status, 200, 'the process still answers');
assert.equal((await call(port, { path: '/api/config', host })).body.requests ?? false, false);
assert.deepEqual(crashes, []);
});
test('in preview mode, named or loopback, a request URL is refused by the listener and the process keeps serving', async t => {
const crashes = [];
const onCrash = err => crashes.push(err);
process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash));
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview, bus, log: () => {} });
t.after(() => close(web));
const port = web.address().port;
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason' }), EXPECTED_WITHOUT_OWNER, 'the named host, signed in by the proxy header');
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason', origin: `https://${preview.host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER);
const unsigned = await call(port, { path: '/api/requests/r1', host: preview.host });
assert.equal(unsigned.status, 401, 'the proxy header is still required first');
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, `127.0.0.1:${port}`), EXPECTED_WITHOUT_OWNER, 'a loopback Host');
assert.equal((await call(port, { path: '/api/config', host: preview.host, headers: { 'x-user': 'jason' } })).status, 200, 'the process still answers');
assert.deepEqual(crashes, []);
});
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');
});