diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 5e59408d6..f5c24df70 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5995,3 +5995,7 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 after | Verified Important I1 with three real Chromium regressions on 7b51dbbba: delayed A inbox, delayed A trail and failed delayed A trail with cached fallback all left B displayed but sent A confirmation URLs (0 pass/3 fail, no fixture/module errors). Root cause: decisionView mutated shared answer before render discarded obsolete HTML; fallback could start after a newer render too. Pass the originating render validity closure (generation, exact route, signed-in state) through both live and kept-data view paths and check before decision answer-state mutation; use the same check before committing page HTML. Small decision-state fix only; no renderer cleanup. GREEN I1 3/3; covering browser/SSO/HTTP 28/28; full WebUI 512/512, zero failures/skips/cancellations. Full output retains M1 ignored-bare-import warning at unchanged prosemirror-transform mark.ts import; deferred per controller. No bus file changed or bus suite rerun, no lifecycle/editor/rename/dependency/tracker/live changes, no subagent or reviewer, no push/merge/PR. Controller supplies fresh re-check. Prior evidence preserved; round 1 logs/report under /mnt/storage/scratch/tmp/mosaic-797-. 2026-11-03 | Lead Codex, Console stage 1 integration (#797, Gitea #1839) | Squash-integrated reviewed worker range 7e849a7b0..95b867cf8 onto refactor 831d15f0b. Only BUILD-LOG/SESSIONS conflicted; preserved canonical bytes and appended worker deltas. Every non-log changed file byte-matches reviewed 95b867cf8. Independent R1 found I1 delayed-read answer-state race; fix 7b51dbbba..95b867cf8 has real-browser RED 0/3 then GREEN 3/3; independent R2 I1 ADDRESSED, no new breakage. Fresh sequential integrated suites: bus 1407/1407 and WebUI 512/512, no failures/skips (scratch mosaic-797-integrated-{bus,webui}.log). M1 unchanged editor-build warning deferred; original T27 transient retained as an unproven timing observation. No approved-PRD/role/schema/config/host/deployment change. Broader lifecycle remains open. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 lifecycle Task 1 before | Prepared t3/0c734dbd checkout at assigned base 1e1357cb6e97825f6080bee15ab45efce794b137. Scope: durable pre-task intake journal, strict 3n→3o offline migration, trusted input provenance, host lifecycle foundation and authorized request.open/get/projects/propose. Three enumerated choices remain user-gated via project.bootstrap in both closed action lists only. Later resource creation, PRD activation, scoped tasks, launches and delivery remain outside this unit; absent host preparation/admission capabilities must leave a durable setup wait. Controller owns queue/tracker coordination and independent review. Red tests precede implementation; local tested commit only. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 lifecycle Task 1 after | Added host-only createRequestStore/createRequestLifecycle, strict CAS transition reduction, byte-preserving intake from existing user-origin human.input/message records, immutable mode/association plan bindings, latest-evidence effect reservations, and broker-derived authorization for request.open/get/projects/propose. Autonomous/guided/existing are enumerated selections; existing stages a filtered software-project picker and a separate exact-association confirmation. PM preparation uses existing message.send authority and rechecks its live claim after awaits; only the owner resolves the user-routed nonblocking project.bootstrap decisions, with null task/requirement refs. No resource creation, PRD activation, scoped tasks, role launch or artifact execution. Missing preparation/admission/bootstrap/attachment remains an explicit setup wait; oversize full resource displays retain source bytes and wait at the existing decision size limit. Added five append-only lifecycle tables, immutable new 3n→3o migration, strict source/digest/version checks and fixture-only offline CLI/old-engine refusal evidence. Existing migrations, approved PRD, role JSON, Console code/verbs, queue/tracker and live config/service/secrets untouched. Minimal collateral: older migration test expectations advance to 3o; role Markdown names the newly gated action; stack-release restore counting includes all five new timed tables. RED intake 0/12; further binding RED 15/16, origin/association RED 21/23, setup/display RED 23/25 and receipt/path RED 23/25. Final intake 26/26; required intake/schema/store/hard-waits 41/41; full bus 1433/1433; full business 296/296; bounded CLI restore/accounting 7/7; sandbox import closure 1/1, all zero failures/skips. Prior full bus 1389 pass/30 fail comprised 27 changed-schema/closed-vocabulary expectations and three managed-launch failures traced to harness-binary-missing; restored existing pinned dependencies with npm ci --ignore-scripts, lockfile unchanged, then full bus 1425/1425 and final 1433/1433. Prior business 295/296 required the two existing hard-wait prose lists to match the closed vocabulary. Test fixture setup mistakes (tester switch, halted-before-launch and incomplete library mapping) were corrected without weakening assertions. Retained evidence: /mnt/storage/scratch/tmp/mosaic-797-task1-logs/; report: /mnt/storage/scratch/tmp/mosaic-797-lifecycle-task-1-report.md. Controller owns independent review and integration; local commit only, no push/merge/PR/deployment. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 463c59518..8e217446c 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1989,3 +1989,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-03 | GPT-6.1 Sol implementation sub-agent, original t3/b15e72fa checkout | #797 / Gitea #1839 Console fix round 1, reviewer I1 | Real-browser delayed inbox/trail and cached-error races RED 0/3 -> GREEN 3/3; originating generation/route guard protects answer-state mutation and HTML commit; covering 28/28 and full WebUI 512/512, zero skips; M1 warning retained, controller re-check pending; local commit only, no bus/lifecycle/editor/rename/tracker/live changes. 2026-11-03 | Lead Codex GPT-6.1 Sol integration | #797 / Gitea #1839 general Console answer buttons | Independent review and scoped fix re-review clean; integrated bus 1407/1407 and WebUI 512/512, zero skips; logs merged append-only and source matches reviewed worker head; code integration only, no running-instance deployment; continue durable pre-task intake. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/0c734dbd | #797 / Gitea #1839 lifecycle Task 1 | Durable pre-task intake, trusted origin/provenance, revision/plan-bound user selections, host-injected preparation/admission foundation and explicit setup waits; strict append-only 3n→3o offline migration with no old migration edits. Intake 26/26, required checks 41/41, full bus 1433/1433, business 296/296, bounded CLI restore/accounting 7/7 and sandbox import 1/1, zero skips; initial RED and schema/dependency/fixture failures retained or described in BUILD-LOG/report. Existing pinned dependencies restored via lockfile only. Controller owns queue/tracker, independent review and integration; tested local commit only; no subsequent lifecycle task or live/authority/Console changes. diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index e6b038ba7..37f7e609e 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -530,3 +530,91 @@ CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; +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','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; +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 TABLE request_effects ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version=1), + business TEXT NOT NULL, + request TEXT NOT NULL REFERENCES events(id), + op TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('bootstrap','prd-submit','prd-activate','task-create','continuation','delivery')), + digest TEXT NOT NULL CHECK (length(digest)=64 AND digest NOT GLOB '*[^0-9a-f]*'), + stage TEXT NOT NULL CHECK (stage IN ('prepared','committed','uncertain','refused')), + result TEXT CHECK (result IS NULL OR (json_valid(result) AND json_type(result)='object')), + code TEXT, + CHECK (stage<>'prepared' OR (result IS NULL AND code IS NULL)), + UNIQUE (business,request,op,stage) +) STRICT; +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 TABLE request_snapshots ( + id TEXT PRIMARY KEY, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object' + AND json_extract(body,'$.version')=1 AND json_extract(body,'$.origin')='product' + AND json_extract(body,'$.id')=id AND json_extract(body,'$.business')=business AND json_extract(body,'$.project')=project) +) STRICT; +CREATE TABLE request_snapshot_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision>=1), + snapshot TEXT NOT NULL REFERENCES request_snapshots(id), + kind TEXT NOT NULL CHECK (kind IN ('activated','revoked')), + cause TEXT NOT NULL, + UNIQUE (business,project,revision) +) STRICT; +CREATE TABLE request_project_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision>=1), + kind TEXT NOT NULL CHECK (kind IN ('attached','mode.changed')), + body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'), + UNIQUE (business,project,revision) +) STRICT; +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; +CREATE TRIGGER request_effects_no_update BEFORE UPDATE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_effects_no_delete BEFORE DELETE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_effects_no_replace BEFORE INSERT ON request_effects WHEN EXISTS (SELECT 1 FROM request_effects WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND op=NEW.op AND stage=NEW.stage)) BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_snapshots_no_update BEFORE UPDATE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshots_no_delete BEFORE DELETE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshots_no_replace BEFORE INSERT ON request_snapshots WHEN EXISTS (SELECT 1 FROM request_snapshots WHERE id=NEW.id) BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_update BEFORE UPDATE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_delete BEFORE DELETE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snapshot_events WHEN EXISTS (SELECT 1 FROM request_snapshot_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; +CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; +CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f8a16adf9..8cd26df89 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -12,6 +12,7 @@ import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, cr // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ 'task.create', + 'project.bootstrap', 'task.assign', 'task.schedule', 'task.update.assigned', @@ -404,6 +405,7 @@ function checkBusiness(name, b) { export class Broker { #store; + #requests; #businesses; #sessions = new Map(); #runs = new Map(); @@ -480,7 +482,7 @@ export class Broker { #clock = 0; #processing; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); @@ -557,6 +559,76 @@ export class Broker { } } for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b); + // Host injection keeps the journal and filesystem modules outside the worker import graph. + if (requestLifecycle !== null) { + if (typeof requestLifecycle !== 'function') throw new TypeError('requestLifecycle must be a host factory'); + this.#requests = requestLifecycle({ + dataRoot, + origin: (identity, request) => this.#requestOrigin(identity, request), + authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation), + project: (business, project) => { + const p = this.#business(business).projects?.[project]; + return p && p.kind !== 'library' ? structuredClone(p) : null; + }, + projectPaths: business => Object.values(this.#business(business).projects ?? {}).flatMap(p => [p.root, p.repo]).filter(p => typeof p === 'string'), + raiseDecision: (identity, args) => this.#dispatch(identity, 'decision.raise', args), + readDecision: (business, decision) => { + const d = this.#store.get('SELECT * FROM decisions WHERE business=? AND id=?', business, decision); + if (!d || d.action !== 'project.bootstrap' || d.route_to !== 'human') fail('request-selection-refused'); + const close = this.#closed(decision); + if (close?.op === 'resolved' && close.via === 'console' && !this.#consoleInput(business, decision, close)) fail('request-selection-refused'); + return close ? { ...close } : null; + }, + record: (identity, operation, state) => { + 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 }); + }, + }); + if (!this.#requests || !['open','get','projects','propose','resume','recover'].every(k => typeof this.#requests[k] === 'function')) + throw new TypeError('invalid request lifecycle'); + } + } + #requestAuthorize(s, state, operation) { + 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.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'); + } else { + this.#agent(s); + 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 }); + } + } + #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); + const body = e && JSON.parse(e.body); + const m = body?.kind === 'instruction' && body.message === e.subject + ? this.#store.get("SELECT * FROM messages WHERE business=? AND id=? AND from_role='human'", s.business, body.message) : null; + if (!m) fail('request-origin-refused'); + const mt = this.#store.get('SELECT mt.thread,mt.session,t.owner FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=?', m.id); + if (mt && mt.owner !== b.human) fail('request-origin-refused'); + const pms = Object.keys(b.roles).filter(r => b.roles[r].definition === 'pm'); + const pm = s.human ? (pms.includes(m.to_role) ? m.to_role : pms.length === 1 ? pms[0] : null) : s.role; + if (!pm || !pms.includes(pm)) fail('request-access-refused'); + this.#requestAuthorize(s, { business: s.business, owner: b.human, pm }, 'get'); + if (!s.human) { + // Only direct receipt or an existing, broker-authorized reply/relay chain exposes an origin. + const reachable = this.#store.get(`WITH RECURSIVE reachable(id,to_role) AS ( + SELECT id,to_role FROM messages WHERE business=? AND id=? + UNION SELECT m.id,m.to_role FROM messages m JOIN reachable r ON m.in_reply_to=r.id WHERE m.business=? + ) SELECT 1 FROM reachable WHERE to_role=? LIMIT 1`, s.business, m.id, s.business, pm); + if (!reachable) fail('request-origin-refused'); + this.#checkMessaging(s.business, pm, 'human'); + this.#checkMessaging(s.business, 'human', pm); + } + return { business: s.business, request, source_message: m.id, thread: mt?.thread ?? null, session: mt?.session ?? null, + owner: b.human, pm, text: m.body }; } // Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would // load it. It is synchronous, so no request sees half of it. A new human is row 193's ground. @@ -4884,6 +4956,15 @@ 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 (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); + 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); }); + } // Row 213 (C2-08, Case 2 design 5.1 M6): a creative role has no free-text decision channel. It // runs before the spec-cited branch below, so a cited raise refuses too. if (request.verb === 'decision.raise' && !s.human && !s.tester && diff --git a/packages/bus/src/migrations/3n-3o.sql b/packages/bus/src/migrations/3n-3o.sql new file mode 100644 index 000000000..dda88012e --- /dev/null +++ b/packages/bus/src/migrations/3n-3o.sql @@ -0,0 +1,88 @@ +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','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; +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 TABLE request_effects ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version=1), + business TEXT NOT NULL, + request TEXT NOT NULL REFERENCES events(id), + op TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('bootstrap','prd-submit','prd-activate','task-create','continuation','delivery')), + digest TEXT NOT NULL CHECK (length(digest)=64 AND digest NOT GLOB '*[^0-9a-f]*'), + stage TEXT NOT NULL CHECK (stage IN ('prepared','committed','uncertain','refused')), + result TEXT CHECK (result IS NULL OR (json_valid(result) AND json_type(result)='object')), + code TEXT, + CHECK (stage<>'prepared' OR (result IS NULL AND code IS NULL)), + UNIQUE (business,request,op,stage) +) STRICT; +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 TABLE request_snapshots ( + id TEXT PRIMARY KEY, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object' + AND json_extract(body,'$.version')=1 AND json_extract(body,'$.origin')='product' + AND json_extract(body,'$.id')=id AND json_extract(body,'$.business')=business AND json_extract(body,'$.project')=project) +) STRICT; +CREATE TABLE request_snapshot_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision>=1), + snapshot TEXT NOT NULL REFERENCES request_snapshots(id), + kind TEXT NOT NULL CHECK (kind IN ('activated','revoked')), + cause TEXT NOT NULL, + UNIQUE (business,project,revision) +) STRICT; +CREATE TABLE request_project_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision>=1), + kind TEXT NOT NULL CHECK (kind IN ('attached','mode.changed')), + body TEXT NOT NULL CHECK (json_valid(body) AND json_type(body)='object'), + UNIQUE (business,project,revision) +) STRICT; +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; +CREATE TRIGGER request_effects_no_update BEFORE UPDATE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_effects_no_delete BEFORE DELETE ON request_effects BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_effects_no_replace BEFORE INSERT ON request_effects WHEN EXISTS (SELECT 1 FROM request_effects WHERE seq=NEW.seq OR (business=NEW.business AND request=NEW.request AND op=NEW.op AND stage=NEW.stage)) BEGIN SELECT RAISE(ABORT, 'request_effects is append-only'); END; +CREATE TRIGGER request_snapshots_no_update BEFORE UPDATE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshots_no_delete BEFORE DELETE ON request_snapshots BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshots_no_replace BEFORE INSERT ON request_snapshots WHEN EXISTS (SELECT 1 FROM request_snapshots WHERE id=NEW.id) BEGIN SELECT RAISE(ABORT, 'request_snapshots is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_update BEFORE UPDATE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_delete BEFORE DELETE ON request_snapshot_events BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snapshot_events WHEN EXISTS (SELECT 1 FROM request_snapshot_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_snapshot_events is append-only'); END; +CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; +CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; +CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs new file mode 100644 index 000000000..3ac0cfb83 --- /dev/null +++ b/packages/bus/src/request-lifecycle.mjs @@ -0,0 +1,183 @@ +// Host controller for pre-task intake. Task 1 prepares and records selections only. +// 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'; + +export function createRequestLifecycle({ journal, services }) { + if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable'); + const pending = new Map(); + const serial = (business, request, fn) => { + const k = business + '/' + request; + const job = (pending.get(k) ?? Promise.resolve()).catch(() => {}).then(fn); + pending.set(k, job); + return job.finally(() => { if (pending.get(k) === job) pending.delete(k); }); + }; + const stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found'); + const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null }); + const authorize = (identity, state, operation) => services.authorize(identity, state, operation); + const append = (state, kind, body, identity = null) => journal.appendRequest({ business: state.business, request: state.request, + expectedRevision: state.revision, kind, body, actor: actor(identity) }); + const wait = (state, code, identity = null, proposal = {}) => { + if (state.wait?.kind === 'setup' && state.wait.code === code && !proposal.op) return state; + return append(state, 'setup.wait', { code, subject: state.request, ...proposal }, identity); + }; + async function open(identity, args) { + exact(args, ['request']); if (!id(args.request)) refuse('invalid-request'); + return serial(identity.business, args.request, () => { + const source = services.origin(identity, args.request); + const old = journal.readRequest(identity.business, args.request); + if (old) { authorize(identity, old, 'get'); return old; } + const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm }; + authorize(identity, state, 'open'); + return journal.transaction(tx => { + const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: 0, kind: 'opened', actor: actor(identity), + body: { source_message: source.source_message, thread: source.thread, session: source.session, owner: source.owner, pm: source.pm, + intent: { text: source.text, sha256: digest(source.text), source_message: source.source_message, generated: false } } }); + services.record?.(identity, 'request.open', result); return result; + }); + }); + } + async function get(identity, args) { + exact(args, ['request']); if (!id(args.request)) refuse('invalid-request'); + const state = stateOf(identity.business, args.request); authorize(identity, state, 'get'); return state; + } + async function projects(identity, args) { + const state = await get(identity, args); + const rows = await services.listProjects?.(structuredClone(state), identity) ?? []; + authorize(identity, stateOf(state.business, state.request), 'get'); + if (!Array.isArray(rows)) refuse('invalid-request-projects'); + return rows.filter(p => object(p) && p.business === state.business && key(p.project) && services.project(state.business, p.project) && + text(p.name, 200) && object(p.tracker) && Number.isSafeInteger(p.tracker.root) && p.tracker.root > 0 && Number.isSafeInteger(p.tracker.project) && p.tracker.project > 0) + .map(p => ({ business: state.business, project: p.project, name: p.name, tracker: { root: p.tracker.root, project: p.tracker.project } })); + } + function localPlan(p, state, args, source) { + validatePlan(p); + if (p.business !== state.business || p.request !== state.request || p.project !== args.project || p.name !== args.name || + p.source.project !== args.source_project || p.tracker.project !== args.tracker_project || p.staff.pm !== state.pm || + p.intent.text !== source.text || p.intent.source_message !== source.source_message || p.intent.generated !== false) refuse('request-plan-binding-refused'); + const mapping = services.project(state.business, p.source.project); + if (!mapping || (mapping.remote ?? null) !== p.source.remote) refuse('request-plan-binding-refused'); + if (p.existing && (p.records_root !== mapping.root || p.code_root !== mapping.repo)) refuse('request-plan-binding-refused'); + // No inferred resource names or filesystem effects: preparation must supply exact roots. + if (!p.existing) { + if (services.project(state.business, p.project)) refuse('request-project-collision'); + if (!services.dataRoot || p.code_root !== join(services.dataRoot, 'products', p.business, p.project, 'code') || + p.records_root !== join(services.dataRoot, 'projects', p.business, p.project)) refuse('request-plan-path-refused'); + for (const path of [p.code_root, p.records_root]) { + for (const declared of services.projectPaths?.(state.business) ?? []) + if (path === declared || path.startsWith(declared + '/') || declared.startsWith(path + '/')) refuse('request-plan-path-collision'); + for (let parent = path; ; parent = dirname(parent)) { + try { + const stat = lstatSync(parent); + if (stat.isSymbolicLink() || !stat.isDirectory() || parent === path || parent === dirname(p.code_root)) refuse('request-plan-path-collision'); + } catch (e) { if (e.code !== 'ENOENT') throw e; } + if (parent === services.dataRoot) break; + if (parent === dirname(parent)) refuse('request-plan-path-refused'); + } + } + } + return p; + } + async function admission(plan, state) { + if (!services.admitPlan) return 'request-admission-unavailable'; + const result = await services.admitPlan(structuredClone(plan), structuredClone(state)); + if (!object(result) || typeof result.ready !== 'boolean' || Object.keys(result).some(k => !['ready','code'].includes(k)) || + (result.ready && result.code !== undefined) || (!result.ready && !text(result.code, 128))) refuse('invalid-request-admission'); + return result.ready ? null : result.code; + } + async function propose(identity, args) { + exact(args, ['request','revision','op','source_project','project','name','tracker_project']); + if (!id(args.request) || !Number.isSafeInteger(args.revision) || args.revision < 1 || !id(args.op) || !key(args.source_project) || + !key(args.project) || !text(args.name, 200) || /[\r\n]/.test(args.name) || !Number.isSafeInteger(args.tracker_project) || args.tracker_project < 1) refuse('invalid-request'); + return serial(identity.business, args.request, async () => { + let state = stateOf(identity.business, args.request); authorize(identity, state, 'propose'); + const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op); + if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; } + if (state.revision !== args.revision) refuse('request-revision-conflict'); + if (state.phase !== 'selection' || state.wait?.kind === 'decision') refuse('request-phase-refused'); + const source = services.origin(identity, args.request); + if (!services.project(state.business, args.source_project)) refuse('request-project-refused'); + const existing = state.wait?.code === 'request-existing-project'; + if (!existing && services.project(state.business, args.project)) refuse('request-project-collision'); + let candidate = null; + if (existing) { + candidate = (await projects(identity, { request: args.request })).find(p => p.project === args.project && p.tracker.project === args.tracker_project && p.name === args.name); + if (!candidate || args.source_project !== args.project) refuse('request-project-refused'); + } + const prepare = existing ? services.prepareExisting : services.preparePlans; + const recheck = () => { + const current = stateOf(state.business, state.request); authorize(identity, current, 'propose'); + if (current.revision !== args.revision) refuse('request-revision-conflict'); + return current; + }; + const pendingWait = code => wait(recheck(), code, identity, { op: args.op, proposal_digest }); + if (!prepare) return pendingWait('request-preparation-unavailable'); + const prepared = await prepare(structuredClone(state), structuredClone(args), structuredClone(source)); + recheck(); + // Preparation may be unable to name real resources at all. It reports that before any + // proposal or decision exists; it must never substitute fabricated paths or tracker IDs. + if (object(prepared) && prepared.ready === false) { + exact(prepared, ['ready','code'], [], 'invalid-request-plan'); + if (!text(prepared.code, 128)) refuse('invalid-request-plan'); + return pendingWait(prepared.code); + } + const plans = existing ? [prepared] : prepared; + if (!Array.isArray(plans) || plans.length !== (existing ? 1 : 2)) refuse('invalid-request-plan'); + for (let i = 0; i < plans.length; i++) { + const p = localPlan(plans[i], state, args, source); + if (p.existing !== existing || (!existing && p.mode !== ['autonomous','guided'][i]) || (existing && p.tracker.root !== candidate.tracker.root)) refuse('request-plan-binding-refused'); + const missing = await admission(p, state); recheck(); if (missing) return pendingWait(missing); + } + // Both modes must display the same concrete resources and original intent. + if (!existing && digest({ ...plans[0], mode: null }) !== digest({ ...plans[1], mode: null })) refuse('request-plan-binding-refused'); + state = recheck(); + const { mode, ...resources } = plans[0]; + const question = `Confirm the resource plan for ${args.name}. ${existing ? `Existing project mode: ${mode}.` : 'Both new project choices use these resources with the selected mode.'} Original user intent and exact resources:\n${JSON.stringify(resources)}`; + // Preserve the ordinary decision channel's existing display limit and all source bytes. + if (question.length > 4096) return pendingWait('request-plan-display-too-large'); + return journal.transaction(tx => { + authorize(identity, state, 'propose'); + const options = existing ? [{ key: 'confirm', text: 'Confirm this existing project association' }, { key: 'back', text: 'Choose another existing project' }] + : [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }]; + const decision = services.raiseDecision(identity, { action: 'project.bootstrap', question, options, recommendation: options[0].key, + approvalChoice: options[0].key, blocking: false }); + const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: state.revision, kind: 'selection.proposed', actor: actor(identity), + body: { op: args.op, proposal_digest, decision: decision.id, kind: existing ? 'existing' : 'mode', + bindings: plans.map((plan, i) => ({ plan, digest: digest(plan), decision: decision.id, choice: options[i].key })) } }); + services.record?.(identity, 'request.propose', result); return result; + }); + }); + } + async function resume(business, request) { + return serial(business, request, async () => { + const state = stateOf(business, request); + if (state.wait?.kind !== 'decision') return state; + const close = services.readDecision(business, state.selection.decision); + if (!close || close.op === 'seen') return state; + if (close.op === 'withdrawn' || close.op === 'expired') return append(state, 'cancelled', { decision: state.selection.decision }); + if (close.op !== 'resolved' || close.by !== state.owner || !['cli','console'].includes(close.via)) refuse('request-selection-refused'); + if (close.choice === 'existing' || close.choice === 'back') return append(state, 'selection.staged', { decision: state.selection.decision, choice: close.choice }); + const binding = state.selection.bindings.find(b => b.choice === close.choice); + if (!binding) refuse('request-selection-refused'); + const p = binding.plan; + if (p.existing) { + const mappings = await services.listProjects?.(structuredClone(state)) ?? []; + if (!Array.isArray(mappings) || !mappings.some(m => m.business === business && m.project === p.project && m.name === p.name && + m.tracker?.root === p.tracker.root && m.tracker?.project === p.tracker.project)) return wait(state, 'request-existing-project-changed'); + } + try { + localPlan(p, state, { project: p.project, name: p.name, source_project: p.source.project, tracker_project: p.tracker.project }, + { text: p.intent.text, source_message: state.source_message }); + } catch (e) { + if (!e.code?.startsWith('request-')) throw e; + return wait(state, e.code); + } + const missing = await admission(binding.plan, state); + if (missing) return wait(state, missing); + return append(state, 'selection.confirmed', { decision: binding.decision, choice: binding.choice, digest: binding.digest }); + }); + } + return Object.freeze({ open, get, projects, propose, resume, + recover: async () => { const states = []; for (const { business, request } of journal.listRequests()) states.push(await resume(business, request)); return states; } }); +} diff --git a/packages/bus/src/request-store.mjs b/packages/bus/src/request-store.mjs new file mode 100644 index 000000000..d4bfbd47e --- /dev/null +++ b/packages/bus/src/request-store.mjs @@ -0,0 +1,174 @@ +// Trusted host journal. No SQL, transition or effect interface is exposed to workers. +import { createHash } from 'node:crypto'; +import { isAbsolute, normalize } from 'node:path'; +import { ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs'; +import { checkVars } from '../../business/src/vars.mjs'; + +export class RequestError extends Error { + constructor(code) { super(code); this.code = code; } +} +export const refuse = code => { throw new RequestError(code); }; +export const object = x => x !== null && typeof x === 'object' && !Array.isArray(x); +export const text = (x, max = 32768) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0'); +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 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); +} +const canonical = x => Array.isArray(x) ? x.map(canonical) : object(x) + ? Object.fromEntries(Object.keys(x).sort().map(k => [k, canonical(x[k])])) : 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 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']); + if (p.version !== 1 || !key(p.business) || !id(p.request) || !key(p.project) || !text(p.name, 200) || /[\r\n]/.test(p.name) || + !nullableId(p.chat_project) || !['autonomous','guided'].includes(p.mode) || !text(p.organization_revision, 256) || typeof p.existing !== 'boolean') refuse(code); + shape(p.source, ['project','commit','remote','forks']); + if (!key(p.source.project) || !oid(p.source.commit) || (p.source.remote !== null && !parseGiteaUrl(p.source.remote, { repo: true }).url) || !Array.isArray(p.source.forks)) refuse(code); + const instances = new Set(); + for (const f of p.source.forks) { + shape(f, ['instance','url','grant','state']); + if (!key(f.instance) || instances.has(f.instance) || !parseGiteaUrl(f.url, { repo: true }).url || !nullableId(f.grant) || !['verified','not-granted'].includes(f.state)) refuse(code); + instances.add(f.instance); + } + for (const path of [p.code_root, p.records_root]) if (!text(path, 4096) || !isAbsolute(path) || normalize(path) !== path) refuse(code); + if (p.code_root === p.records_root || p.code_root.startsWith(p.records_root + '/') || p.records_root.startsWith(p.code_root + '/')) refuse(code); + shape(p.tracker, ['root','project']); + if (!positive(p.tracker.root) || !positive(p.tracker.project)) refuse(code); + shape(p.staff, ['pm','technical_reviewer','coder','reviewer','chief']); + if (!Object.values(p.staff).every(key) || p.staff.pm === p.staff.technical_reviewer) refuse(code); + shape(p.intent, ['text','sha256','source_message','generated']); + if (!text(p.intent.text) || !hash(p.intent.sha256) || digest(p.intent.text) !== p.intent.sha256 || !id(p.intent.source_message) || typeof p.intent.generated !== 'boolean') refuse(code); + if (!object(p.project_vars) || Object.hasOwn(p.project_vars, 'tracker.project')) refuse(code); + try { checkVars(p.project_vars, 'project', 'request resource plan'); } catch { refuse(code); } + return p; +} +function initial(business, request, b) { + exact(b, ['source_message','thread','session','owner','pm','intent'], [], 'invalid-request-event'); + if (!id(b.source_message) || !nullableId(b.thread) || !nullableId(b.session) || !id(b.owner) || !key(b.pm)) refuse('invalid-request-event'); + exact(b.intent, ['text','sha256','source_message','generated'], [], 'invalid-request-event'); + if (!text(b.intent.text) || digest(b.intent.text) !== b.intent.sha256 || b.intent.source_message !== b.source_message || b.intent.generated !== false) refuse('invalid-request-event'); + return { version: 1, request, business, revision: 1, source_message: b.source_message, thread: b.thread, session: b.session, + owner: b.owner, pm: b.pm, phase: 'selection', project: null, chat_project: null, mode: null, mode_revision: 0, + plan_digest: null, spec_commit: null, snapshot: null, tasks: [], delivery: null, question: null, candidate: null, selection: null, wait: null }; +} +function reduce(state, e) { + const code = 'invalid-request-event', b = e.body; + if (e.kind === 'opened') { if (state || e.revision !== 1) refuse(code); return initial(e.business, e.request, b); } + if (!state || e.revision !== state.revision + 1 || state.phase === 'cancelled') refuse(code); + const s = structuredClone(state); s.revision = e.revision; + if (e.kind === 'setup.wait') { + exact(b, ['code','subject'], ['op','proposal_digest'], code); + if (!text(b.code, 128) || !id(b.subject) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code); + s.wait = { kind: 'setup', code: b.code, subject: b.subject, resume_phase: s.phase, observed_revision: s.revision }; + } else if (e.kind === 'selection.proposed') { + exact(b, ['op','proposal_digest','decision','bindings','kind'], [], code); + if (s.phase !== 'selection' || s.wait?.kind === 'decision' || !id(b.op) || !hash(b.proposal_digest) || !id(b.decision) || !['mode','existing'].includes(b.kind) || !Array.isArray(b.bindings)) refuse(code); + const choices = b.kind === 'mode' ? ['autonomous','guided'] : ['confirm']; + if (b.bindings.length !== choices.length) refuse(code); + for (let i = 0; i < choices.length; i++) { + const binding = b.bindings[i]; exact(binding, ['plan','digest','decision','choice'], [], code); validatePlan(binding.plan); + if (binding.choice !== choices[i] || binding.decision !== b.decision || binding.digest !== digest(binding.plan) || + binding.plan.business !== s.business || binding.plan.request !== s.request || binding.plan.staff.pm !== s.pm || + binding.plan.intent.source_message !== s.source_message || binding.plan.existing !== (b.kind === 'existing') || + (b.kind === 'mode' && binding.plan.mode !== binding.choice)) refuse(code); + } + s.selection = { decision: b.decision, bindings: structuredClone(b.bindings) }; + s.wait = { kind: 'decision', code: b.kind === 'mode' ? 'request-mode' : 'request-existing-confirmation', subject: b.decision, resume_phase: 'selection', observed_revision: s.revision }; + } else if (e.kind === 'selection.staged') { + exact(b, ['decision','choice'], [], code); + if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision || !['existing','back'].includes(b.choice) || + (b.choice === 'existing') !== (s.wait.code === 'request-mode')) refuse(code); + s.selection = null; + s.wait = { kind: 'setup', code: 'request-existing-project', subject: s.request, resume_phase: 'selection', observed_revision: s.revision }; + } else if (e.kind === 'selection.confirmed') { + exact(b, ['decision','choice','digest'], [], code); + const binding = s.selection?.bindings.find(p => p.decision === b.decision && p.choice === b.choice && p.digest === b.digest); + if (s.wait?.kind !== 'decision' || !binding) refuse(code); + const p = binding.plan; + s.project = p.project; s.chat_project = p.chat_project; s.mode = p.mode; s.mode_revision = 1; s.plan_digest = binding.digest; + s.phase = p.existing ? 'discovery' : 'bootstrap'; + s.wait = { kind: 'setup', code: p.existing ? 'request-attachment-unavailable' : 'request-bootstrap-unavailable', subject: s.request, resume_phase: s.phase, observed_revision: s.revision }; + } else if (e.kind === 'cancelled') { + exact(b, ['decision'], [], code); + if (s.wait?.kind !== 'decision' || s.selection?.decision !== b.decision) refuse(code); + s.phase = 'cancelled'; s.wait = null; s.selection = null; + } else refuse(code); + return s; +} +export function createRequestStore(store) { + const validateIntent = (event, origin) => { + if (event.kind === 'selection.proposed' && (!Array.isArray(event.body.bindings) || + event.body.bindings.some(b => !object(b?.plan?.intent) || !equal(b.plan.intent, origin?.intent)))) refuse('invalid-request-event'); + }; + const readRequest = (business, request) => { + if (!key(business) || !id(request)) refuse('invalid-request'); + const events = store.all('SELECT * FROM request_events WHERE business=? AND request=? ORDER BY revision', business, request) + .map(e => ({ ...e, body: JSON.parse(e.body) })); + return events.reduce((s, e) => { validateIntent(e, events[0].body); return reduce(s, e); }, null); + }; + // appendInTransaction is only for composing the decision and journal atomically on the host. + const appendInTransaction = e => { + exact(e, ['business','request','expectedRevision','kind','body','actor']); + exact(e.actor, ['role','run']); + if (!key(e.business) || !id(e.request) || !Number.isSafeInteger(e.expectedRevision) || e.expectedRevision < 0 || + !((e.actor.role === null && e.actor.run === null) || (key(e.actor.role) && id(e.actor.run)))) refuse('invalid-request-event'); + const state = readRequest(e.business, e.request); + if ((state?.revision ?? 0) !== e.expectedRevision) refuse('request-revision-conflict'); + const next = reduce(state, { ...e, revision: e.expectedRevision + 1 }); + if (e.kind === 'opened') { + const source = store.get(`SELECT m.id,m.body,mt.thread,mt.session FROM events e JOIN messages m ON m.id=e.subject + AND m.id=json_extract(e.body,'$.message') AND m.business=e.business + LEFT JOIN message_threads mt ON mt.message=m.id + WHERE e.business=? AND e.id=? 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'`, e.business, e.request); + if (!source || source.id !== e.body.source_message || source.body !== e.body.intent?.text || + (source.thread ?? null) !== e.body.thread || (source.session ?? null) !== e.body.session) refuse('request-origin-refused'); + } + if (e.kind === 'selection.proposed') { + const origin = JSON.parse(store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='opened'", e.business, e.request).body); + validateIntent(e, origin); + const d = store.get('SELECT * FROM decisions WHERE business=? AND id=?', e.business, e.body.decision); + 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'); + } + store.run('INSERT INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body) VALUES(?,?,?,?,?,?,?,?)', + e.business, e.request, next.revision, e.kind, e.actor.role, e.actor.run, new Date().toISOString(), JSON.stringify(e.body)); + return next; + }; + function receipt(r, prepare) { + exact(r, ['version','business','request','op','kind','digest','stage','result','code'], [], 'invalid-request-effect'); + if (r.version !== 1 || !key(r.business) || !id(r.request) || !id(r.op) || !hash(r.digest) || + !['bootstrap','prd-submit','prd-activate','task-create','continuation','delivery'].includes(r.kind) || + !(prepare ? r.stage === 'prepared' && r.result === null && r.code === null : ['committed','uncertain','refused'].includes(r.stage)) || + !(r.result === null || object(r.result)) || !(r.code === null || text(r.code, 128))) refuse('invalid-request-effect'); + return store.transaction(() => { + if (!readRequest(r.business, r.request)) refuse('request-not-found'); + const rows = store.all('SELECT version,business,request,op,kind,digest,stage,result,code FROM request_effects WHERE business=? AND request=? AND op=? ORDER BY seq', r.business, r.request, r.op) + .map(x => ({ ...x, result: x.result === null ? null : JSON.parse(x.result) })); + if (rows.some(x => x.digest !== r.digest || x.kind !== r.kind)) refuse('request-effect-conflict'); + // A retry must see committed/uncertain/refused evidence instead of executing again. + if (prepare && rows.length) return structuredClone(rows.at(-1)); + const old = rows.find(x => x.stage === r.stage); + if (old) { if (!equal(old, r)) refuse('request-effect-conflict'); return structuredClone(r); } + if (!prepare && !rows.some(x => x.stage === 'prepared')) refuse('request-effect-unprepared'); + if (rows.some(x => ['committed','refused'].includes(x.stage))) refuse('request-effect-conflict'); + store.run('INSERT INTO request_effects(at,version,business,request,op,kind,digest,stage,result,code) VALUES(?,?,?,?,?,?,?,?,?,?)', + new Date().toISOString(), r.version, r.business, r.request, r.op, r.kind, r.digest, r.stage, r.result === null ? null : JSON.stringify(r.result), r.code); + return structuredClone(r); + }); + } + return Object.freeze({ readRequest, appendRequest: e => store.transaction(() => appendInTransaction(e)), + prepareEffect: r => receipt(r, true), finishEffect: r => receipt(r, false), + transaction: fn => store.transaction(() => fn({ appendRequest: appendInTransaction, readRequest })), + listRequests: () => store.all('SELECT DISTINCT business,request FROM request_events ORDER BY business,request'), + proposal: (business, request, op) => { + const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND json_extract(body,'$.op')=? ORDER BY seq DESC LIMIT 1", business, request, op); + return row ? JSON.parse(row.body) : null; + } }); +} diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 97fe6310d..44eb24bb1 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -2,6 +2,8 @@ import { lstatSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Store } from './store.mjs'; +import { createRequestStore } from './request-store.mjs'; +import { createRequestLifecycle } from './request-lifecycle.mjs'; import * as projectModule from '../../project/src/index.mjs'; import * as libraryModule from '../../library/src/index.mjs'; import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs'; @@ -254,6 +256,8 @@ export async function startBroker({ consoleOwners = [], // P1 validated sidecar from the trusted host; never a socket request setting. proxy = null, + // Host-only preparation/admission capabilities; missing capabilities leave a setup wait. + requestServices = {}, }) { if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) @@ -286,6 +290,7 @@ export async function startBroker({ // Row 199 (#1697, design D1): the per-agent store is /secrets/agents. const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null; store = new Store(dataRoot); + let requestLifecycle; // Store's writer lock admits one bus per data root before it holds the management client. if (proxy) { if (!organization) throw new BusError('not-configured'); @@ -487,6 +492,7 @@ export async function startBroker({ } } broker = new Broker({ + requestLifecycle: services => (requestLifecycle = createRequestLifecycle({ journal: createRequestStore(store), services: { ...requestServices, ...services } })), // FED R3: the access file is read per user request; a refusal leaves the user no grant. access: access ? () => loadAccess(access.file, access.context) : null, // FED R4 (#1801, design 3.7 trigger 1): a request stats the file and reads it only when this @@ -877,8 +883,10 @@ export async function startBroker({ usageTimer = setInterval(usageTick, 1000); usageTimer.unref(); usageTick(); // Optional telemetry never blocks the native ready answer. + await requestLifecycle.recover(); return { broker, + requestLifecycle, credentials, // P2 consumes this inside the bus only. No process IPC/verb returns it. get modelProxy() { return modelProxyClient; }, diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 273be7812..1aaa5033f 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -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 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7'; -export const SCHEMA_VERSION = '3n'; +export const SCHEMA_SHA256 = '5a055f1687704043439d28c5ed2f1b2131b3c3d1af6dfdb1aa4f3cdc01dc01d6'; +export const SCHEMA_VERSION = '3o'; // 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'; @@ -82,6 +82,11 @@ const migrations = [{ from: '3m', to: '3n', sql: readFileSync(new URL('./migrations/3m-3n.sql', import.meta.url), 'utf8'), +}, { + // #797 / #1839 Task 1: append-only request lifecycle evidence. Explicit offline migration only. + from: '3n', + to: '3o', + sql: readFileSync(new URL('./migrations/3n-3o.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', @@ -102,6 +107,11 @@ const timedTables = [ 'usage_records', 'session_ops', 'message_cancels', + 'request_events', + 'request_effects', + 'request_snapshots', + 'request_snapshot_events', + 'request_project_events', ]; const hex = (value) => createHash('sha256').update(value).digest('hex'); if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch'); diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs index a65e02bf2..2862617ac 100644 --- a/packages/bus/tests/account-migration.test.mjs +++ b/packages/bus/tests/account-migration.test.mjs @@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url); const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3h(t) { const root = mkdtempSync(join(tmpdir(), 'account-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -88,6 +88,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql')); copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql')); 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')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } @@ -95,7 +96,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), '3n'); + assert.equal(version(path), '3o'); // 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]); @@ -145,7 +146,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), '3n'); + assert.equal(version(path), '3o'); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); }); @@ -157,14 +158,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 = '3n';", "export const SCHEMA_VERSION = '3h';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); assert.deepEqual(master(path), was); assert.deepEqual(snapshot(path, after.columns).rows, after.rows); }); diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index 8186994f8..05c1f6680 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -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 = '3n';", "export const SCHEMA_VERSION = '3c';") + .replace("export const SCHEMA_VERSION = '3o';", "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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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 }); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs index c661a2b49..1d10ecc6e 100644 --- a/packages/bus/tests/comment-migration.test.mjs +++ b/packages/bus/tests/comment-migration.test.mjs @@ -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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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 = '3n';", "export const SCHEMA_VERSION = '3o';") - .replace('}];\nconst timedTables', `}, { from: '3n', to: '3o', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); + .replace("export const SCHEMA_VERSION = '3o';", "export const SCHEMA_VERSION = '3p';") + .replace('}];\nconst timedTables', `}, { from: '3o', to: '3p', 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']) + 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']) 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, '3o'); + assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3p'); 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 3n ready/); + assert.match(migrated.stdout, /bus schema 3o ready/); new Store(root).close(); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); assert.equal(usage.status, 2); diff --git a/packages/bus/tests/gitea-migration.test.mjs b/packages/bus/tests/gitea-migration.test.mjs index f8aa649d1..ea7b55718 100644 --- a/packages/bus/tests/gitea-migration.test.mjs +++ b/packages/bus/tests/gitea-migration.test.mjs @@ -36,11 +36,11 @@ function source3j(t) { const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -78,6 +78,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql')); copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql')); 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')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -99,7 +100,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), '3n'); + assert.equal(version(path), '3o'); 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))); @@ -125,7 +126,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), '3n'); + assert.equal(version(path), '3o'); }); test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -136,14 +137,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 = '3n';", "export const SCHEMA_VERSION = '3j';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/grant-migration.test.mjs b/packages/bus/tests/grant-migration.test.mjs index baa9ba326..0680bc8ee 100644 --- a/packages/bus/tests/grant-migration.test.mjs +++ b/packages/bus/tests/grant-migration.test.mjs @@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3i(t) { const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -42,7 +42,7 @@ function source3i(t) { function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -81,6 +81,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql')); copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql')); 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')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -104,7 +105,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), '3n'); + assert.equal(version(path), '3o'); 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))); @@ -129,7 +130,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), '3n'); + assert.equal(version(path), '3o'); }); test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -140,14 +141,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 = '3n';", "export const SCHEMA_VERSION = '3i';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index 524c34a1a..ed9e05579 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -11,7 +11,7 @@ import { hostGrantRecord, hostRecord } from './host-fixtures.mjs'; // PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633). const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external', - 'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy']; + 'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy', 'project.bootstrap']; const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke']; const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit }); const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }); diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs index e6b80e7cf..921c4722d 100644 --- a/packages/bus/tests/name-migration.test.mjs +++ b/packages/bus/tests/name-migration.test.mjs @@ -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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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) }); diff --git a/packages/bus/tests/request-intake.test.mjs b/packages/bus/tests/request-intake.test.mjs new file mode 100644 index 000000000..20af68dba --- /dev/null +++ b/packages/bus/tests/request-intake.test.mjs @@ -0,0 +1,408 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, chmodSync, existsSync } 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 } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { Client } from '../src/client.mjs'; +const journalModule = await import('../src/request-store.mjs').catch(e => { + if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; +}); +const lifecycleModule = await import('../src/request-lifecycle.mjs').catch(e => { + if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; +}); +const hash = x => createHash('sha256').update(x).digest('hex'); +const intent = 'Build a single-file timer.\nKeep these exact bytes.\n'; +function setup(t, { prepared = false, authority = true, halted = false, configureHost = () => {}, userText = intent } = {}) { + assert.equal(typeof journalModule.createRequestStore, 'function', 'host intake journal exists'); + assert.equal(typeof lifecycleModule.createRequestLifecycle, 'function', 'host lifecycle exists'); + const root = mkdtempSync(join(tmpdir(), 'request-intake-')); + let store = new Store(root), lifecycle, journal, cleanupRuntime; + let processingHalted = false; + t.after(async () => { await cleanupRuntime?.close(); 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'].map(key => [key, { + definition: key === 'otherpm' ? 'pm' : key, + authority: { withinRole: authority || key !== 'pm' ? ['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') } } }; + let revision = 'org-1'; + 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: revision, + source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] }, + code_root: existing ? business.projects[args.project].repo : join(root, 'products/demo', args.project, 'code'), + records_root: existing ? business.projects[args.project].root : 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 }, + ], + ...(prepared ? { + preparePlans: (state, args, source) => plans(state, args, source, false), + prepareExisting: (state, args, source) => plans(state, args, source, true)[0], + admitPlan: plan => plan.organization_revision === revision ? { ready: true } : { ready: false, code: 'organization-stale' }, + } : {}), + }; + configureHost(host); + const factory = services => { + journal = journalModule.createRequestStore(store); + lifecycle = lifecycleModule.createRequestLifecycle({ journal, services: { ...host, ...services } }); + return lifecycle; + }; + const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: factory, + ...(halted ? { processing: { read: () => ({ version: 1, halted: processingHalted, generation: '12345678-1234-4234-8234-123456789abc' }) } } : {}) }); + 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: { provider: 'oidc', iss: 'https://id.test', sub: 'jason' } }) }; + 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 = (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const source = call('human', 'message.send', { to: 'pm', body: userText }); + processingHalted = halted; + const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', + source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 }); + return { root, b, store, journal, lifecycle, host, call, source, propose, business, + runtimeCleanup: runtime => { cleanupRuntime = runtime; }, + changeOrganization: () => { revision = 'org-2'; }, + reopen: () => { store.close(); store = new Store(root); return journalModule.createRequestStore(store); } }; +} +test('opens_without_task_or_requirement', async t => { + const x = setup(t), state = await x.call('pm', 'request.open', { request: x.source.request }); + assert.equal(state.request, x.source.request); assert.equal(state.source_message, x.source.id); + assert.equal(state.phase, 'selection'); assert.equal(state.owner, 'jason'); assert.equal(state.project, null); + assert.deepEqual(state.tasks, []); assert.equal(state.snapshot, null); + assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0); + assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent); +}); +test('duplicate_open_is_same_request', async t => { + const x = setup(t), args = { request: x.source.request }; + const [a, b] = await Promise.all([x.call('pm', 'request.open', args), x.call('pm', 'request.open', args)]); + assert.deepEqual(a, b); assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 1); + assert.deepEqual(await x.call('human', 'request.get', args), a); +}); +test('rejects_foreign_input', async t => { + const x = setup(t); + for (const request of [x.source.id, 'absent']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/); + x.store.run("INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)", 'foreign', new Date().toISOString(), 'foreign', 'human.input', JSON.stringify({ kind: 'instruction', message: x.source.id })); + x.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)", 'forged', new Date().toISOString(), 'demo', 'human.input', 'coder', 'coder-run', x.source.id, JSON.stringify({ kind: 'instruction', message: x.source.id })); + for (const request of ['foreign', 'forged']) await assert.rejects(async () => x.call('pm', 'request.open', { request }), /request-origin-refused/); + await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request, text: 'forged' }), /invalid-request/); + assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0); +}); +test('non_owner_cannot_select', async t => { + const x = setup(t, { prepared: true }); + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(opened)); + for (const who of ['pm', 'cto', 'coder', 'otherpm']) assert.throws(() => x.call(who, 'decision.resolve', { id: state.selection.decision, choice: 'guided' }), /human-required/); + await assert.rejects(async () => x.call('otherpm', 'request.propose', x.propose(state)), /request-access-refused/); + const preview = x.call('console', 'decision.preview.console', { id: state.selection.decision }); + assert.equal(preview.kind, 'decision'); assert.deepEqual(preview.options.map(o => o.key), ['autonomous', 'guided', 'existing']); + const d = x.store.get('SELECT * FROM decisions WHERE id=?', state.selection.decision); + assert.equal(d.route_to, 'human'); assert.equal(d.action, 'project.bootstrap'); assert.equal(d.blocking, 0); + assert.equal(d.task_ref, null); assert.equal(d.requirement_ref, null); + const raise = JSON.parse(x.store.get("SELECT body FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.operation')='decision.raise'", d.id).body); + assert.equal(raise.approvalChoice, 'autonomous'); +}); +test('reopen_store_reduces_same_state', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(a)); + assert.deepEqual(x.reopen().readRequest('demo', x.source.request), state); +}); +test('missing_resources_opens_one_setup_wait', async t => { + const x = setup(t), a = await x.call('pm', 'request.open', { request: x.source.request }); + const one = await x.call('pm', 'request.propose', x.propose(a)); + assert.equal(one.wait.kind, 'setup'); assert.equal(one.wait.code, 'request-preparation-unavailable'); + assert.deepEqual(await x.lifecycle.resume('demo', a.request), one); + assert.deepEqual(await x.lifecycle.recover(), [one]); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0); + assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='setup.wait'").n, 1); +}); +test('guided_is_a_selection_and_execution_waits_for_later_capability', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(a)); + x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'guided' }); + const selected = await x.lifecycle.resume('demo', a.request); + assert.equal(selected.mode, 'guided'); assert.equal(selected.phase, 'bootstrap'); + assert.equal(selected.plan_digest, state.selection.bindings.find(b => b.choice === 'guided').digest); + assert.equal(selected.wait.code, 'request-bootstrap-unavailable'); + assert.deepEqual(await x.lifecycle.resume('demo', a.request), selected); + assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0); + assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0); +}); +test('stale_revision_and_changed_plan_refuse_before_confirmation', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(a)); + await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(a), op: 'plan-2' }), /request-revision-conflict/); + x.changeOrganization(); x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'autonomous' }); + const stopped = await x.lifecycle.resume('demo', a.request); + assert.equal(stopped.phase, 'selection'); assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'organization-stale'); +}); +test('existing_picker_filters_authorized_software_mappings_then_confirms_exact_association', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(a)); + assert.deepEqual((await x.call('pm', 'request.projects', { request: a.request })).map(p => p.project), ['old']); + x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' }); + const picker = await x.lifecycle.resume('demo', a.request); + assert.equal(picker.phase, 'selection'); assert.equal(picker.project, null); assert.equal(picker.wait.code, 'request-existing-project'); + const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'existing-1', source_project: 'old', project: 'old', name: 'Old' }); + assert.equal(candidate.project, null); assert.equal(candidate.selection.bindings[0].plan.existing, true); + const d = x.call('console', 'decision.preview.console', { id: candidate.selection.decision }); + assert.deepEqual(d.options.map(o => o.key), ['confirm', 'back']); + x.call('console', 'decision.resolve.console', { id: d.id, choice: 'confirm', digest: d.digest }); + const bound = await x.lifecycle.resume('demo', a.request); + assert.equal(bound.project, 'old'); assert.equal(bound.snapshot, null); assert.deepEqual(bound.tasks, []); + assert.equal(bound.wait.code, 'request-attachment-unavailable'); +}); +test('withdrawal_cancels_without_resources_and_preserves_source', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(a)); + x.call('human', 'decision.withdraw', { id: state.selection.decision }); + const cancelled = await x.lifecycle.resume('demo', a.request); + assert.equal(cancelled.phase, 'cancelled'); assert.equal(cancelled.project, null); + assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, intent); + assert.equal(x.store.get('SELECT count(*) n FROM request_effects').n, 0); +}); +test('journal_cas_strict_events_and_append_only_sql_guards', async t => { + const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request }); + const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'setup.wait', + actor: { role: null, run: null }, body: { code: 'resource-missing', subject: state.request } }; + x.journal.appendRequest(event); + assert.throws(() => x.journal.appendRequest(event), /request-revision-conflict/); + assert.throws(() => x.journal.appendRequest({ ...event, expectedRevision: 2, body: { ...event.body, state: {} } }), /invalid-request-event/); + for (const sql of ['UPDATE request_events SET kind=kind', 'DELETE FROM request_events', + 'INSERT OR REPLACE INTO request_events SELECT * FROM request_events']) assert.throws(() => x.store.run(sql), /append-only/); +}); +test('effect_receipts_reserve_digest_kind_and_terminal_result_once', async t => { + const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request }); + const r = { version: 1, business: 'demo', request: state.request, op: 'boot-1', kind: 'bootstrap', digest: hash('plan'), stage: 'prepared', result: null, code: null }; + assert.deepEqual(x.journal.prepareEffect(r), r); assert.deepEqual(x.journal.prepareEffect(r), r); + assert.throws(() => x.journal.prepareEffect({ ...r, digest: hash('other') }), /request-effect-conflict/); + const done = { ...r, stage: 'committed', result: { project: 'timer' } }; + assert.deepEqual(x.journal.finishEffect(done), done); assert.deepEqual(x.journal.finishEffect(done), done); + assert.deepEqual(x.journal.prepareEffect(r), done, 'retry receives the committed evidence, never a fresh execution reservation'); + assert.throws(() => x.journal.finishEffect({ ...done, result: { project: 'other' } }), /request-effect-conflict/); + assert.throws(() => x.journal.finishEffect({ ...done, op: 'unprepared' }), /request-effect-unprepared/); + for (const sql of ['UPDATE request_effects SET kind=kind', 'DELETE FROM request_effects', + 'INSERT OR REPLACE INTO request_effects SELECT * FROM request_effects']) assert.throws(() => x.store.run(sql), /append-only/); +}); +test('request_verbs_retain_role_grant_processing_and_origin_visibility_gates', async t => { + const x = setup(t, { authority: false }); + await assert.rejects(async () => x.call('pm', 'request.open', { request: x.source.request }), /decision-required/); + for (const who of ['reader','tester','coder','cto','otherpm']) await assert.rejects(async () => x.call(who, 'request.open', { request: x.source.request }), /read-only|tester-refused|request-access-refused|request-origin-refused/); + assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0); + const halted = setup(t, { halted: true }); + await assert.rejects(async () => halted.call('pm', 'request.open', { request: halted.source.request }), /processing-halted/); + assert.equal(halted.store.get('SELECT count(*) n FROM request_events').n, 0); +}); +test('proposal_is_idempotent_but_op_revision_plan_and_resource_bindings_are_strict', async t => { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const args = x.propose(a), state = await x.call('pm', 'request.propose', args); + assert.deepEqual(await x.call('pm', 'request.propose', args), state); + await assert.rejects(async () => x.call('pm', 'request.propose', { ...args, name: 'Other' }), /request-operation-conflict/); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 1); + const proposed = JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='selection.proposed'").body); + const plan = proposed.bindings[0].plan; + const bad = structuredClone(plan); bad.intent.text = 'Forged original'; bad.intent.sha256 = hash(bad.intent.text); + assert.throws(() => x.journal.appendRequest({ business: 'demo', request: a.request, expectedRevision: state.revision, + kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: { ...proposed, + bindings: [{ ...proposed.bindings[0], plan: bad, digest: journalModule.digest(bad) }, proposed.bindings[1]] } }), /invalid-request-event/); +}); +test('plan_preparation_rejects_unknown_keys_changed_intent_tracker_modes_and_symlinks', async t => { + for (const fault of ['extra','intent','tracker','mode','symlink','partial','overlap']) { + const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); + const original = x.host.preparePlans; + x.host.preparePlans = (state, args, source) => { + const plans = original(state, args, source); + if (fault === 'extra') plans[0].authority = 'all'; + if (fault === 'intent') { plans[0].intent.text = 'forged'; plans[0].intent.sha256 = hash('forged'); } + if (fault === 'tracker') plans[0].tracker.project = 99; + if (fault === 'mode') plans[1].source.commit = 'b'.repeat(40); + return plans; + }; + // The factory takes a host snapshot; construct a second controller for these adversarial hooks. + const fakeIdentity = { business: 'demo', role: 'pm', run: 'pm-run' }; + const controller = lifecycleModule.createRequestLifecycle({ journal: x.journal, services: { ...x.host, dataRoot: x.root, + origin: () => ({ owner: 'jason', pm: 'pm', source_message: x.source.id, thread: null, session: null, text: intent }), + authorize: () => {}, project: (_b, p) => x.business.projects[p]?.kind === 'library' ? null : x.business.projects[p], + projectPaths: () => Object.values(x.business.projects).flatMap(p => [p.root, p.repo]).filter(Boolean), + raiseDecision: () => assert.fail('invalid plan must not raise'), readDecision: () => null } }); + if (fault === 'symlink') symlinkSync(x.root, join(x.root, 'products')); + if (fault === 'partial') { const parent = join(x.root, 'products/demo/timer'); mkdirSync(parent, { recursive: true }); writeFileSync(join(parent, 'unrelated.txt'), 'unrelated'); } + if (fault === 'overlap') x.business.projects.other = { root: join(x.root, 'products/demo/timer') }; + await assert.rejects(() => controller.propose(fakeIdentity, x.propose(a)), /invalid-request-plan|request-plan-binding-refused|request-plan-path-collision/, fault); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0, fault); + } +}); +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 }); + const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); + const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8'); + assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length); + const { schemaDigest, SCHEMA_VERSION } = awaitImportStore; + const db = new DatabaseSync(path); db.exec(oldDDL); + db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version','3n'); + db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest',schemaDigest(db)); + db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch','2026-11-03T00:00:00.000Z'); + db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-03T00:00:00.000Z','demo','action.allowed','{}')").run(); + 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 3o ready\n'); assert.equal(SCHEMA_VERSION, '3o'); + 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); + // The actual base engine refuses the migrated fixture. No live config or data is read. + const moduleRoot = join(root, 'old-engine'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true }); + writeFileSync(join(moduleRoot, 'schema.sql'), oldDDL); + const oldSource = spawnSync('git', ['show','1e1357cb6e97825f6080bee15ab45efce794b137:packages/bus/src/store.mjs'], { encoding: 'utf8' }); + assert.equal(oldSource.status, 0); writeFileSync(join(moduleRoot, 'src/store.mjs'), oldSource.stdout); + for (const migration of ['3b','3b-3c','3c-3d','3d-3e','3e-3f','3f-3g','3g-3h','3h-3i','3i-3j','3j-3k','3k-3l','3l-3m','3m-3n']) + writeFileSync(join(moduleRoot, 'src/migrations', migration + '.sql'), readFileSync(new URL('../src/migrations/' + migration + '.sql', import.meta.url))); + const old = spawnSync(process.execPath, ['--input-type=module','-e', `import {Store} from ${JSON.stringify(join(moduleRoot, 'src/store.mjs'))}; try { new Store(${JSON.stringify(root)}); process.exit(9); } catch(e) { if(e.message !== 'schema-mismatch') throw e; }`], { encoding: 'utf8' }); + assert.equal(old.status, 0, old.stderr); +}); +const awaitImportStore = await import('../src/store.mjs'); + +test('real_runtime_socket_loads_host_lifecycle_and_recovery_keeps_intake_without_capabilities', async t => { + const root = mkdtempSync(join(tmpdir(), 'request-runtime-')); + let runtime; + t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); }); + const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, + roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } }; + const launch = async () => { + runtime = await startBroker({ dataRoot: root, businesses: { demo: business } }); + const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + return new Client({ path: runtime.path, cap }); + }; + let client = await launch(); const source = await client.call('message.send', { to: 'pm', body: intent }); + const opened = await client.call('request.open', { request: source.request }); + assert.equal(opened.phase, 'selection'); assert.deepEqual(await client.call('request.get', { request: source.request }), opened); + await runtime.close(); runtime = null; client = await launch(); + assert.deepEqual(await client.call('request.get', { request: source.request }), opened); + assert.deepEqual(await runtime.requestLifecycle.recover(), [opened]); +}); + +test('effect_and_request_unique_keys_refuse_replacement_without_explicit_seq', async t => { + const x = setup(t); const state = await x.call('pm', 'request.open', { request: x.source.request }); + assert.throws(() => x.store.run(`INSERT OR REPLACE INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body) + SELECT business,request,revision,kind,actor_role,actor_run,at,body FROM request_events`), /append-only/); + for (const table of ['request_snapshots','request_snapshot_events','request_project_events']) { + // Exercise triggers with valid rows; the future producers get immutable storage, no writer API here. + if (table === 'request_snapshots') x.store.run(`INSERT INTO request_snapshots(id,at,business,project,body) VALUES('snapshot',?,'demo','old',?)`, new Date().toISOString(), JSON.stringify({version:1,id:'snapshot',business:'demo',project:'old',origin:'product'})); + else if (table === 'request_snapshot_events') x.store.run(`INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,'demo','old',1,'snapshot','activated','fixture')`, new Date().toISOString()); + else x.store.run(`INSERT INTO request_project_events(at,business,project,revision,kind,body) VALUES(?,'demo','old',1,'attached','{}')`, new Date().toISOString()); + for (const sql of [`UPDATE ${table} SET at=at`, `DELETE FROM ${table}`, `INSERT OR REPLACE INTO ${table} SELECT * FROM ${table}`]) assert.throws(() => x.store.run(sql), /append-only/); + } + const event = { business: 'demo', request: state.request, expectedRevision: state.revision, kind: 'selection.proposed', actor: { role: 'pm', run: 'pm-run' }, body: {} }; + assert.throws(() => x.journal.appendRequest(event), /invalid-request-event/); +}); + +test('preparation_rechecks_live_pm_claim_before_recording_any_decision', async t => { + let start, release; + const started = new Promise(r => { start = r; }), gate = new Promise(r => { release = r; }); + const x = setup(t, { prepared: true, configureHost: host => { + const prepare = host.preparePlans; host.preparePlans = async (...args) => { start(); await gate; return prepare(...args); }; + } }); + const state = await x.call('pm', 'request.open', { request: x.source.request }); + const proposed = x.call('pm', 'request.propose', x.propose(state)); + await started; x.call('pm', 'role.release'); release(); + await assert.rejects(() => proposed, /not-holder/); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0); + assert.equal(x.journal.readRequest('demo', state.request).revision, state.revision); +}); + +test('admission_is_required_and_setup_wait_cannot_claim_ready', async t => { + const x = setup(t, { prepared: true, configureHost: host => { delete host.admitPlan; } }); + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const state = await x.call('pm', 'request.propose', x.propose(opened)); + assert.equal(state.wait.code, 'request-admission-unavailable'); assert.equal(state.selection, null); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0); +}); + +test('undeclared_projects_and_changed_existing_tracker_mapping_refuse', async t => { + const x = setup(t, { prepared: true }); const state = await x.call('pm', 'request.open', { request: x.source.request }); + await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'library' }), /request-project-refused/); + await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(state), source_project: 'foreign' }), /request-project-refused/); + const selected = await x.call('pm', 'request.propose', x.propose(state)); + x.call('human', 'decision.resolve', { id: selected.selection.decision, choice: 'existing' }); + const picker = await x.lifecycle.resume('demo', state.request); + await assert.rejects(async () => x.call('pm', 'request.propose', { ...x.propose(picker), op: 'bad-picker', source_project: 'old', project: 'old', name: 'Old', tracker_project: 99 }), /request-project-refused/); + assert.equal(x.journal.readRequest('demo', state.request).project, null); +}); + +test('existing_confirmation_rechecks_exact_current_tracker_association', async t => { + let board = 2; + const x = setup(t, { prepared: true, configureHost: host => { host.listProjects = () => [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: board } }]; } }); + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const mode = await x.call('pm', 'request.propose', x.propose(opened)); + x.call('human', 'decision.resolve', { id: mode.selection.decision, choice: 'existing' }); + const picker = await x.lifecycle.resume('demo', opened.request); + const candidate = await x.call('pm', 'request.propose', { ...x.propose(picker), op: 'old-binding', source_project: 'old', project: 'old', name: 'Old' }); + board = 3; + x.call('human', 'decision.resolve', { id: candidate.selection.decision, choice: 'confirm' }); + const stopped = await x.lifecycle.resume('demo', opened.request); + assert.equal(stopped.project, null); assert.equal(stopped.wait.code, 'request-existing-project-changed'); + assert.deepEqual(stopped.tasks, []); +}); + +test('journal_refuses_fabricated_original_text_even_on_host_append', t => { + const x = setup(t); + assert.throws(() => x.journal.appendRequest({ business: 'demo', request: x.source.request, expectedRevision: 0, kind: 'opened', actor: { role: 'pm', run: 'pm-run' }, + body: { source_message: x.source.id, thread: null, session: null, owner: 'jason', pm: 'pm', + intent: { text: 'fabricated', sha256: hash('fabricated'), source_message: x.source.id, generated: false } } }), /request-origin-refused/); + assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0); +}); + +test('preparation_can_report_a_precise_setup_wait_without_inventing_a_plan', async t => { + const x = setup(t, { prepared: true, configureHost: host => { host.preparePlans = () => ({ ready: false, code: 'tracker-board-unavailable' }); } }); + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const waiting = await x.call('pm', 'request.propose', x.propose(opened)); + assert.equal(waiting.wait.code, 'tracker-board-unavailable'); assert.equal(waiting.project, null); assert.equal(waiting.selection, null); + assert.deepEqual(await x.lifecycle.recover(), [waiting]); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0); +}); + +test('oversize_resource_display_keeps_exact_intake_in_one_explicit_setup_wait', async t => { + const userText = 'Original user intent\n' + 'a'.repeat(6000); + const x = setup(t, { prepared: true, userText }); + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const waiting = await x.call('pm', 'request.propose', x.propose(opened)); + assert.equal(waiting.wait.code, 'request-plan-display-too-large'); + assert.equal(JSON.parse(x.store.get("SELECT body FROM request_events WHERE kind='opened'").body).intent.text, userText); + assert.equal(x.store.get('SELECT count(*) n FROM decisions').n, 0); +}); + +test('runtime_restart_recovers_a_recorded_autonomous_selection_once_without_bootstrap_effects', async t => { + const x = setup(t, { prepared: true }); const opened = await x.call('pm', 'request.open', { request: x.source.request }); + const selection = await x.call('pm', 'request.propose', x.propose(opened)); + x.call('human', 'decision.resolve', { id: selection.selection.decision, choice: 'autonomous' }); + x.store.close(); + const business = { ...x.business, projects: Object.fromEntries(Object.entries(x.business.projects).filter(([, p]) => p.kind !== 'library')) }; + const runtime = await startBroker({ dataRoot: x.root, businesses: { demo: business }, requestServices: x.host }); + x.runtimeCleanup(runtime); + const cap = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const client = new Client({ path: runtime.path, cap }); + const recovered = await client.call('request.get', { request: opened.request }); + assert.equal(recovered.phase, 'bootstrap'); assert.equal(recovered.mode, 'autonomous'); + assert.equal(recovered.wait.code, 'request-bootstrap-unavailable'); + assert.equal(recovered.plan_digest, selection.selection.bindings[0].digest); + assert.deepEqual(await runtime.requestLifecycle.recover(), [recovered]); + assert.deepEqual(recovered.tasks, []); assert.equal(recovered.snapshot, null); + assert.equal(existsSync(join(x.root, 'products')), false); + assert.equal(existsSync(join(x.root, 'projects')), false); +}); diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs index 0f8b2878e..f397cfaa6 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -524,7 +524,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => { // Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them. // Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them. // Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them. - assert.equal(count('table') - 1, 25); - assert.equal(count('trigger'), 96); + // #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards. + assert.equal(count('table') - 1, 30); + assert.equal(count('trigger'), 114); assert.equal(count('view'), 5); }); diff --git a/packages/bus/tests/session-clear.test.mjs b/packages/bus/tests/session-clear.test.mjs index aced59453..34ca4dbe4 100644 --- a/packages/bus/tests/session-clear.test.mjs +++ b/packages/bus/tests/session-clear.test.mjs @@ -530,9 +530,9 @@ 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), '3n'); + assert.equal(version(path), '3o'); const after = tables(path); - for (const name of ['session_ops', 'message_cancels']) { + 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); delete after[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 = '3n';", "export const SCHEMA_VERSION = '3m';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); 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. diff --git a/packages/bus/tests/sessions.test.mjs b/packages/bus/tests/sessions.test.mjs index 7b15ab2ef..563723535 100644 --- a/packages/bus/tests/sessions.test.mjs +++ b/packages/bus/tests/sessions.test.mjs @@ -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), '3n'); + assert.equal(version(path), '3o'); // 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 = '3n';", "export const SCHEMA_VERSION = '3k';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); 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. diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs index 8c3b738b2..9967f7783 100644 --- a/packages/bus/tests/spec-migration.test.mjs +++ b/packages/bus/tests/spec-migration.test.mjs @@ -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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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'), []); diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs index 5c9e65c4f..d0b35794a 100644 --- a/packages/bus/tests/thread-migration.test.mjs +++ b/packages/bus/tests/thread-migration.test.mjs @@ -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 = '3n';", "export const SCHEMA_VERSION = '3d';") + .replace("export const SCHEMA_VERSION = '3o';", "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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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 3n ready', ''].join('\n')); + assert.equal(migrated.stdout, [...REPORT, 'bus schema 3o ready', ''].join('\n')); }); diff --git a/packages/bus/tests/thread-shares.test.mjs b/packages/bus/tests/thread-shares.test.mjs index 0c586b703..769a1a19e 100644 --- a/packages/bus/tests/thread-shares.test.mjs +++ b/packages/bus/tests/thread-shares.test.mjs @@ -403,12 +403,12 @@ 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), '3n'); + assert.equal(version(path), '3o'); const after = tables(path); assert.deepEqual(after.thread_shares, []); delete after.thread_shares; // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. - for (const name of ['session_ops', 'message_cancels']) { + 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); delete after[name]; } @@ -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 = '3n';", "export const SCHEMA_VERSION = '3l';") + .replace("export const SCHEMA_VERSION = '3o';", "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), '3n'); + assert.equal(version(path), '3o'); 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. diff --git a/packages/bus/tests/usage-migration.test.mjs b/packages/bus/tests/usage-migration.test.mjs index 904ce584c..7aba25d9e 100644 --- a/packages/bus/tests/usage-migration.test.mjs +++ b/packages/bus/tests/usage-migration.test.mjs @@ -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, '3n'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o'); 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'); diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 8bd463c96..3e6e4d971 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -10,6 +10,7 @@ // effect and isn't listed. export const ACTIONS = Object.freeze([ "task.create", + "project.bootstrap", "task.assign", "task.schedule", "task.update.assigned", diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/business/tests/hard-waits.test.mjs index 9ff377176..4b10c17a1 100644 --- a/packages/business/tests/hard-waits.test.mjs +++ b/packages/business/tests/hard-waits.test.mjs @@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs"; import { tmp } from "./helpers.mjs"; const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external", - "git.push.protected", "deploy", "git.push.remote", "host.sudo"]; + "git.push.protected", "deploy", "git.push.remote", "host.sudo", "project.bootstrap"]; const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"]; const role = (authority) => { const dir = tmp(); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 23ed8141a..aac93b70e 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -859,7 +859,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' '); const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1]; // sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as // well as T46. -const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels']; +const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']; const LATE='2999-01-01T00:00:00.000Z'; const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()}; diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md index ab4d78a79..dff66408b 100644 --- a/roles/chief-of-staff.md +++ b/roles/chief-of-staff.md @@ -77,7 +77,7 @@ raise that cites the project's current spec commit, goes to the business's arbiter for its domain. Pass `domain` to `raise_decision`, `technical` or `delivery`; leaving it out means `delivery`. An uncited gated raise, and every hard wait, goes to the human through the inbox. -The hard waits are `spend`, `data.delete`, `network.expose`, +The hard waits are `project.bootstrap`, `spend`, `data.delete`, `network.expose`, `account.create`, `credential.mint`, `message.external`, `git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a citation never moves them. diff --git a/roles/pm.md b/roles/pm.md index c9f6953b7..b1151145f 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -51,7 +51,7 @@ raise that cites the project's current spec commit, goes to the business's arbiter for its domain. Pass `domain` to `raise_decision`, `technical` or `delivery`; leaving it out means `delivery`. An uncited gated raise, and every hard wait, goes to the human through the inbox. -The hard waits are `spend`, `data.delete`, `network.expose`, +The hard waits are `project.bootstrap`, `spend`, `data.delete`, `network.expose`, `account.create`, `credential.mint`, `message.external`, `git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a citation never moves them. diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 212a206bc..27884f112 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -417,7 +417,7 @@ plugin_checks() { # Row 181: the tables a bus writer fills without an event in the same transaction, in schema # order. A restore drops their rows after STAMP as it drops events, so the guard counts them too. # BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema. -COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels" +COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events" STORE_JS=' const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");