feat(bus): persist pre-task request intake and selection (#1839)

This commit is contained in:
jason.woltje committed 2026-11-03 17:53:45 -06:00
1 parent 1e1357cb6e
commit 3b071cac7b
30 files changed
+1104 -53

No files matched your search

+4
View File
@@ -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.
+2
View File
@@ -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.
+88
View File
@@ -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;
+82 -1
View File
@@ -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 &&
+88
View File
@@ -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;
+183
View File
@@ -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; } });
}
+174
View File
@@ -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;
} });
}
+8
View File
@@ -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; },
+12 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '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);
}); });
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8'); let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '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);
+7 -6
View File
@@ -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);
}); });
+7 -6
View File
@@ -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);
}); });
+1 -1
View File
@@ -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) });
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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) });
+408
View File
@@ -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);
});
+3 -2
View File
@@ -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);
}); });
+4 -4
View File
@@ -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.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t); const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '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.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`) .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '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'));
}); });
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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');
+1
View File
@@ -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",
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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()};
+1 -1
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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");