feat(bus): persist pre-task request intake and selection (#1839)
This commit is contained in:
1 parent
1e1357cb6e
commit
3b071cac7b
30 files changed
+1104
-53
No files matched your search
@@ -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 | 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 | 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.
|
||||||
@@ -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 | 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 | 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.
|
||||||
@@ -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_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_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 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;
|
||||||
@@ -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.
|
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||||
export const ACTIONS = Object.freeze([
|
export const ACTIONS = Object.freeze([
|
||||||
'task.create',
|
'task.create',
|
||||||
|
'project.bootstrap',
|
||||||
'task.assign',
|
'task.assign',
|
||||||
'task.schedule',
|
'task.schedule',
|
||||||
'task.update.assigned',
|
'task.update.assigned',
|
||||||
@@ -404,6 +405,7 @@ function checkBusiness(name, b) {
|
|||||||
|
|
||||||
export class Broker {
|
export class Broker {
|
||||||
#store;
|
#store;
|
||||||
|
#requests;
|
||||||
#businesses;
|
#businesses;
|
||||||
#sessions = new Map();
|
#sessions = new Map();
|
||||||
#runs = new Map();
|
#runs = new Map();
|
||||||
@@ -480,7 +482,7 @@ export class Broker {
|
|||||||
#clock = 0;
|
#clock = 0;
|
||||||
#processing;
|
#processing;
|
||||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
// 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');
|
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.
|
// 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');
|
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);
|
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
|
// 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.
|
// 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 })) };
|
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
|
||||||
}
|
}
|
||||||
this.#processingGate(s, request.verb);
|
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
|
// 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.
|
// runs before the spec-cited branch below, so a cited raise refuses too.
|
||||||
if (request.verb === 'decision.raise' && !s.human && !s.tester &&
|
if (request.verb === 'decision.raise' && !s.human && !s.tester &&
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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; } });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
} });
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { Store } from './store.mjs';
|
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 projectModule from '../../project/src/index.mjs';
|
||||||
import * as libraryModule from '../../library/src/index.mjs';
|
import * as libraryModule from '../../library/src/index.mjs';
|
||||||
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
||||||
@@ -254,6 +256,8 @@ export async function startBroker({
|
|||||||
consoleOwners = [],
|
consoleOwners = [],
|
||||||
// P1 validated sidecar from the trusted host; never a socket request setting.
|
// P1 validated sidecar from the trusted host; never a socket request setting.
|
||||||
proxy = null,
|
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) ||
|
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)))
|
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 <configDir>/secrets/agents.
|
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
|
||||||
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
||||||
store = new Store(dataRoot);
|
store = new Store(dataRoot);
|
||||||
|
let requestLifecycle;
|
||||||
// Store's writer lock admits one bus per data root before it holds the management client.
|
// Store's writer lock admits one bus per data root before it holds the management client.
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
if (!organization) throw new BusError('not-configured');
|
if (!organization) throw new BusError('not-configured');
|
||||||
@@ -487,6 +492,7 @@ export async function startBroker({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
broker = new Broker({
|
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.
|
// 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,
|
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
|
// 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 = setInterval(usageTick, 1000);
|
||||||
usageTimer.unref();
|
usageTimer.unref();
|
||||||
usageTick(); // Optional telemetry never blocks the native ready answer.
|
usageTick(); // Optional telemetry never blocks the native ready answer.
|
||||||
|
await requestLifecycle.recover();
|
||||||
return {
|
return {
|
||||||
broker,
|
broker,
|
||||||
|
requestLifecycle,
|
||||||
credentials,
|
credentials,
|
||||||
// P2 consumes this inside the bus only. No process IPC/verb returns it.
|
// P2 consumes this inside the bus only. No process IPC/verb returns it.
|
||||||
get modelProxy() { return modelProxyClient; },
|
get modelProxy() { return modelProxyClient; },
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||||
export const SCHEMA_SHA256 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7';
|
export const SCHEMA_SHA256 = '5a055f1687704043439d28c5ed2f1b2131b3c3d1af6dfdb1aa4f3cdc01dc01d6';
|
||||||
export const SCHEMA_VERSION = '3n';
|
export const SCHEMA_VERSION = '3o';
|
||||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||||
@@ -82,6 +82,11 @@ const migrations = [{
|
|||||||
from: '3m',
|
from: '3m',
|
||||||
to: '3n',
|
to: '3n',
|
||||||
sql: readFileSync(new URL('./migrations/3m-3n.sql', import.meta.url), 'utf8'),
|
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 = [
|
const timedTables = [
|
||||||
'events',
|
'events',
|
||||||
@@ -102,6 +107,11 @@ const timedTables = [
|
|||||||
'usage_records',
|
'usage_records',
|
||||||
'session_ops',
|
'session_ops',
|
||||||
'message_cancels',
|
'message_cancels',
|
||||||
|
'request_events',
|
||||||
|
'request_effects',
|
||||||
|
'request_snapshots',
|
||||||
|
'request_snapshot_events',
|
||||||
|
'request_project_events',
|
||||||
];
|
];
|
||||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||||
|
|||||||
@@ -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'];
|
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 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.
|
// 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) {
|
function source3h(t) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
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/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/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/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;
|
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);
|
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||||
@@ -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);
|
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||||
// Still a whole 3h store: the real step migrates it.
|
// Still a whole 3h store: the real step migrates it.
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
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,
|
schema: ddl3h,
|
||||||
store: (source) => source
|
store: (source) => source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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'),
|
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||||
});
|
});
|
||||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||||
});
|
});
|
||||||
@@ -614,7 +614,7 @@ async function database3c(t) {
|
|||||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||||
const pinned = source
|
const pinned = source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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');
|
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||||
assert.notEqual(pinned, source);
|
assert.notEqual(pinned, source);
|
||||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||||
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
|||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
t.after(() => store.close());
|
t.after(() => store.close());
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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 body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
|||||||
const original = readFileSync(source);
|
const original = readFileSync(source);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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 body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
|||||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3o';")
|
.replace("export const SCHEMA_VERSION = '3o';", "export const SCHEMA_VERSION = '3p';")
|
||||||
.replace('}];\nconst timedTables', `}, { from: '3n', to: '3o', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
.replace('}];\nconst timedTables', `}, { from: '3o', to: '3p', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql'])
|
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));
|
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||||
const future = new FutureStore(root, { migrateSchema: true });
|
const future = new FutureStore(root, { migrateSchema: true });
|
||||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '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 name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
|||||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||||
assert.equal(migrated.status, 0, migrated.stderr);
|
assert.equal(migrated.status, 0, migrated.stderr);
|
||||||
assert.match(migrated.stdout, /bus schema 3n ready/);
|
assert.match(migrated.stdout, /bus schema 3o ready/);
|
||||||
new Store(root).close();
|
new Store(root).close();
|
||||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||||
assert.equal(usage.status, 2);
|
assert.equal(usage.status, 2);
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ function source3j(t) {
|
|||||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
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 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.
|
// 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) {
|
function rows(path) {
|
||||||
const db = new DatabaseSync(path, { readOnly: true });
|
const db = new DatabaseSync(path, { readOnly: true });
|
||||||
try {
|
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()
|
.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 }))]));
|
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||||
} finally { db.close(); }
|
} 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/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/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/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;
|
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||||
}
|
}
|
||||||
function fresh(t) {
|
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);
|
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(migrated3l(rows(path)), before);
|
assert.deepEqual(migrated3l(rows(path)), before);
|
||||||
const now = master(path);
|
const now = master(path);
|
||||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||||
@@ -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(master(path), was);
|
||||||
assert.deepEqual(rows(path), before);
|
assert.deepEqual(rows(path), before);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '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 => {
|
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,
|
schema: ddl3j,
|
||||||
store: (source) => source
|
store: (source) => source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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'),
|
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||||
});
|
});
|
||||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(rows(path), after);
|
assert.deepEqual(rows(path), after);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'];
|
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 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.
|
// 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) {
|
function source3i(t) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
@@ -42,7 +42,7 @@ function source3i(t) {
|
|||||||
function rows(path) {
|
function rows(path) {
|
||||||
const db = new DatabaseSync(path, { readOnly: true });
|
const db = new DatabaseSync(path, { readOnly: true });
|
||||||
try {
|
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()
|
.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 }))]));
|
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||||
} finally { db.close(); }
|
} 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/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/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/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;
|
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||||
}
|
}
|
||||||
function fresh(t) {
|
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);
|
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(migrated3l(rows(path)), before);
|
assert.deepEqual(migrated3l(rows(path)), before);
|
||||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||||
@@ -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(master(path), was);
|
||||||
assert.deepEqual(rows(path), before);
|
assert.deepEqual(rows(path), before);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '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 => {
|
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,
|
schema: ddl3i,
|
||||||
store: (source) => source
|
store: (source) => source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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'),
|
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||||
});
|
});
|
||||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(rows(path), after);
|
assert.deepEqual(rows(path), after);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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).
|
// 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',
|
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 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 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) });
|
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
|||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
try {
|
try {
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 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 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
|
||||||
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
|
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
|
||||||
assert.equal(count('table') - 1, 25);
|
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
||||||
assert.equal(count('trigger'), 96);
|
assert.equal(count('table') - 1, 30);
|
||||||
|
assert.equal(count('trigger'), 114);
|
||||||
assert.equal(count('view'), 5);
|
assert.equal(count('view'), 5);
|
||||||
});
|
});
|
||||||
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
|
|||||||
const before = tables(path);
|
const before = tables(path);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
const after = tables(path);
|
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);
|
assert.deepEqual(after[name], [], name);
|
||||||
delete after[name];
|
delete after[name];
|
||||||
}
|
}
|
||||||
@@ -571,7 +571,7 @@ async function engine3m(root) {
|
|||||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||||
const edited = source
|
const edited = source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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');
|
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||||
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
|
|||||||
const Store3m = await engine3m(root);
|
const Store3m = await engine3m(root);
|
||||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(tables(path), before);
|
assert.deepEqual(tables(path), before);
|
||||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
|||||||
const { root, path } = source3k(t);
|
const { root, path } = source3k(t);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
// The migrated schema is the fresh one, object for object.
|
// The migrated schema is the fresh one, object for object.
|
||||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
|||||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||||
const edited = source
|
const edited = source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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');
|
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
|||||||
const Store3k = await engine3k(root);
|
const Store3k = await engine3k(root);
|
||||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(rows(path), before);
|
assert.deepEqual(rows(path), before);
|
||||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
|||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
try {
|
try {
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
|||||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||||
const pinned = source
|
const pinned = source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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');
|
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
|||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
t.after(() => store.close());
|
t.after(() => store.close());
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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'])
|
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
|||||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||||
assert.equal(migrated.status, 0, migrated.stderr);
|
assert.equal(migrated.status, 0, migrated.stderr);
|
||||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3n ready', ''].join('\n'));
|
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3o ready', ''].join('\n'));
|
||||||
});
|
});
|
||||||
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
|
|||||||
const before = tables(path);
|
const before = tables(path);
|
||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
new Store(root, { migrateSchema: true }).close();
|
new Store(root, { migrateSchema: true }).close();
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
const after = tables(path);
|
const after = tables(path);
|
||||||
assert.deepEqual(after.thread_shares, []);
|
assert.deepEqual(after.thread_shares, []);
|
||||||
delete after.thread_shares;
|
delete after.thread_shares;
|
||||||
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
|
// 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);
|
assert.deepEqual(after[name], [], name);
|
||||||
delete after[name];
|
delete after[name];
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ async function engine3l(root) {
|
|||||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||||
const edited = source
|
const edited = source
|
||||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
||||||
.replace("export const SCHEMA_VERSION = '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');
|
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||||
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
|
|||||||
const Store3l = await engine3l(root);
|
const Store3l = await engine3l(root);
|
||||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||||
assert.equal(version(path), '3n');
|
assert.equal(version(path), '3o');
|
||||||
assert.deepEqual(master(path), was);
|
assert.deepEqual(master(path), was);
|
||||||
assert.deepEqual(tables(path), before);
|
assert.deepEqual(tables(path), before);
|
||||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
|||||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||||
const store = new Store(root, { migrateSchema: true });
|
const store = new Store(root, { migrateSchema: true });
|
||||||
try {
|
try {
|
||||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
// effect and isn't listed.
|
// effect and isn't listed.
|
||||||
export const ACTIONS = Object.freeze([
|
export const ACTIONS = Object.freeze([
|
||||||
"task.create",
|
"task.create",
|
||||||
|
"project.bootstrap",
|
||||||
"task.assign",
|
"task.assign",
|
||||||
"task.schedule",
|
"task.schedule",
|
||||||
"task.update.assigned",
|
"task.update.assigned",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
|
|||||||
import { tmp } from "./helpers.mjs";
|
import { tmp } from "./helpers.mjs";
|
||||||
|
|
||||||
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
|
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 LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
|
||||||
const role = (authority) => {
|
const role = (authority) => {
|
||||||
const dir = tmp();
|
const dir = tmp();
|
||||||
|
|||||||
@@ -859,7 +859,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
|||||||
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
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
|
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
||||||
// well as T46.
|
// 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 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()};
|
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()};
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||||
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
||||||
gated raise, and every hard wait, goes to the human through the inbox.
|
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`,
|
`account.create`, `credential.mint`, `message.external`,
|
||||||
`git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a
|
`git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a
|
||||||
citation never moves them.
|
citation never moves them.
|
||||||
|
|||||||
+1
-1
@@ -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`,
|
business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||||
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
||||||
gated raise, and every hard wait, goes to the human through the inbox.
|
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`,
|
`account.create`, `credential.mint`, `message.external`,
|
||||||
`git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a
|
`git.push.protected`, `git.push.remote`, `host.sudo` and `deploy`; a
|
||||||
citation never moves them.
|
citation never moves them.
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ plugin_checks() {
|
|||||||
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
|
# 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.
|
# 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.
|
# 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='
|
STORE_JS='
|
||||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||||
|
|||||||
Reference in new issue
Block a user