@tokenoftrust/storefront-runner 2.4.6 → 2.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ -- The candidate preparation ledger has no writer or reader: candidate progress lives in the KV
2
+ -- ReviewEnvironment index, and the release state in the tenant pipeline. Its tables are renamed
3
+ -- aside, not dropped, so the rows survive until a backup is taken; rename back to restore. The
4
+ -- indexes are dropped so their names are free (they are re-creatable from the rows).
5
+ DROP INDEX IF EXISTS `idx_candidate_attempts_tenant`;
6
+ DROP INDEX IF EXISTS `idx_candidate_attempts_candidate`;
7
+ DROP INDEX IF EXISTS `uq_candidate_attempts_candidate_number`;
8
+ ALTER TABLE `candidate_attempts` RENAME TO `retired_candidate_attempts_20261010`;
9
+ DROP INDEX IF EXISTS `idx_candidates_tenant_status`;
10
+ DROP INDEX IF EXISTS `uq_candidates_tenant_change`;
11
+ DROP INDEX IF EXISTS `uq_candidates_tenant_repo_pr`;
12
+ ALTER TABLE `candidates` RENAME TO `retired_candidates_20261010`;
@@ -14,7 +14,7 @@
14
14
  "preview": "wrangler dev",
15
15
  "typecheck": "astro check && tsc --noEmit",
16
16
  "astro": "astro",
17
- "test": "NODE_OPTIONS=--experimental-sqlite vitest run && pnpm run test:pipeline"
17
+ "test": "NODE_OPTIONS=--experimental-sqlite vitest run && pnpm run test:pipeline && pnpm run test:pipeline-faults"
18
18
  },
19
19
  "dependencies": {
20
20
  "@astrojs/cloudflare": "^14.0.1",
@@ -564,94 +564,6 @@ export const newsletterSends = sqliteTable(
564
564
  (t) => ({ pk: primaryKey({ columns: [t.tenantId, t.collection, t.digestId] }) }),
565
565
  );
566
566
 
567
- // --- Canonical publish-candidate identity -----------------------------------
568
- // ONE canonical candidate per `(tenant_id, repository, pr_number)` — the real
569
- // forge PR. Retries/rebuilds are NOT new candidates; they are child `attempts`
570
- // (below). This is the durable identity spine other units resolve against; the
571
- // KV `ReviewEnvironment` (`cust:*`) and aggregate/release models (`rel:*`) are
572
- // read projections that reference this row's `change_id`/`pr_number`.
573
- //
574
- // `id` is a SYNTHETIC surrogate (`cand_<uuid>`), deliberately DISTINCT from the
575
- // canonical key so foreign keys (attempts) never depend on the mutable natural
576
- // key. `change_id` is the REAL forge change id — the stable branch/build
577
- // identity (`candidate/<change_id>`) — preserved, never derived from `id`.
578
- // `tenant_id` is the isolation boundary, written from the resolved session,
579
- // NEVER a client body. The UNIQUE `(tenant_id, repository, pr_number)` is what
580
- // makes "one candidate per PR" a schema invariant (upsert-by-key, not
581
- // insert-then-dedupe); the UNIQUE `(tenant_id, change_id)` keeps the branch/
582
- // build identity unambiguous for resolve-by-changeId callers.
583
- export const candidates = sqliteTable(
584
- "candidates",
585
- {
586
- id: text("id").primaryKey(),
587
- tenantId: text("tenant_id").notNull(),
588
- // The `<tenant>-<tag>` forge repository the PR lives in.
589
- repository: text("repository").notNull(),
590
- // The forge PR number — the third leg of the canonical key.
591
- prNumber: integer("pr_number").notNull(),
592
- // The real forge change id — stable branch/build identity (`candidate/<change_id>`).
593
- changeId: text("change_id").notNull(),
594
- // Source branch, conventionally `candidate/<change_id>` (nullable convenience mirror).
595
- branch: text("branch"),
596
- title: text("title"),
597
- author: text("author"),
598
- // Canonical candidate lifecycle: open | preparing | ready | needs-attention | accepted | rejected | closed.
599
- status: text("status").notNull().default("open"),
600
- // The PR head/base shas as last reconciled (nullable until first read).
601
- headSha: text("head_sha"),
602
- baseSha: text("base_sha"),
603
- createdAt: text("created_at").notNull(),
604
- updatedAt: text("updated_at").notNull(),
605
- },
606
- (t) => ({
607
- uqCandidatesCanonical: uniqueIndex("uq_candidates_tenant_repo_pr").on(
608
- t.tenantId,
609
- t.repository,
610
- t.prNumber,
611
- ),
612
- uqCandidatesChange: uniqueIndex("uq_candidates_tenant_change").on(t.tenantId, t.changeId),
613
- idxCandidatesTenantStatus: index("idx_candidates_tenant_status").on(t.tenantId, t.status),
614
- }),
615
- );
616
-
617
- // One `attempt` = one build/check/rebase EXECUTION of a candidate. Attempts are
618
- // the child rows retries produce — they must NEVER queue as separate top-level
619
- // candidate rows. `candidate_id` references `candidates.id` in code only (no SQL
620
- // FK — repo convention, matches `webhook_deliveries.event_id` / `orders`).
621
- // `tenant_id` is denormalized for tenant-scoped reads/isolation without a join.
622
- // `attempt_number` is monotonic per candidate (1,2,3…) giving stable ordering;
623
- // UNIQUE `(candidate_id, attempt_number)` makes a double-assigned number a loud
624
- // failure rather than a silent duplicate. `kind`: build | check | rebase;
625
- // `status`: pending | running | passed | failed | cancelled (mirrors
626
- // `IntegrationRun`'s state vocabulary). `evidence` is a JSON EvidenceReport
627
- // snapshot; `detail` a human-legible terminal-state message.
628
- export const candidateAttempts = sqliteTable(
629
- "candidate_attempts",
630
- {
631
- id: text("id").primaryKey(),
632
- candidateId: text("candidate_id").notNull(),
633
- tenantId: text("tenant_id").notNull(),
634
- attemptNumber: integer("attempt_number").notNull(),
635
- kind: text("kind").notNull(),
636
- status: text("status").notNull().default("pending"),
637
- // The candidate head sha this attempt executed against, or null if unknown.
638
- headSha: text("head_sha"),
639
- detail: text("detail"),
640
- evidence: text("evidence"),
641
- startedAt: text("started_at").notNull(),
642
- // Null while pending/running; set on a terminal transition.
643
- finishedAt: text("finished_at"),
644
- },
645
- (t) => ({
646
- uqAttemptNumber: uniqueIndex("uq_candidate_attempts_candidate_number").on(
647
- t.candidateId,
648
- t.attemptNumber,
649
- ),
650
- idxAttemptsCandidate: index("idx_candidate_attempts_candidate").on(t.candidateId),
651
- idxAttemptsTenant: index("idx_candidate_attempts_tenant").on(t.tenantId),
652
- }),
653
- );
654
-
655
567
  // One active submit per tenant/change. This serializes identity authorization,
656
568
  // the Gitea mutation, and audit finalization so a verified callback cannot
657
569
  // become stale between its forge read and its KV writes. `expires_at` is
@@ -17,17 +17,17 @@
17
17
  * transient timeout instead of a refusal
18
18
  * - `slow-build` every file of a build takes a few milliseconds (a build that is
19
19
  * still running seconds after its intent was answered)
20
- * The fake also records what each job reported to MCP (`fake_events` kind `report`) and which
21
- * shas the preview could not render (`refused`).
20
+ * The fake also records what each job reported to MCP (`fake_events` kind `report`), which
21
+ * shas the preview could not render (`refused`), and each undo candidate it opened (`undo`).
22
22
  * A fault fires `times` times (0 = every time); its firing is persisted before it acts.
23
23
  */
24
24
  import type { DurableObjectState } from "cloudflare:workers";
25
25
  import { ForgeToolError, ForgeTransportError } from "../../src/lib/forge/errors";
26
- import type {
27
- MembershipEntry,
28
- MergeCandidate,
29
- PreviewJobServices,
30
- ReadModelTip,
26
+ import {
27
+ BUILD_CHECKPOINT_EVERY,
28
+ type MembershipEntry,
29
+ type MergeCandidate,
30
+ type PreviewJobServices,
31
31
  } from "../../src/lib/pipeline/preview-jobs";
32
32
 
33
33
  /** Files a fake build materializes. */
@@ -123,7 +123,14 @@ export function readFakeState(state: DurableObjectState): FakeState {
123
123
  };
124
124
  }
125
125
 
126
- export function fakePreviewServices(state: DurableObjectState): PreviewJobServices {
126
+ /** The file at which a build reports `build:file` to the fault matrix: past its first checkpoint. */
127
+ export const MATRIX_BUILD_FILE = BUILD_CHECKPOINT_EVERY + 5;
128
+
129
+ /**
130
+ * `onEffect` hears the one effect only the fake can name: the build writing a file past its first
131
+ * checkpoint (`build:file`), where an eviction must resume from that checkpoint.
132
+ */
133
+ export function fakePreviewServices(state: DurableObjectState, onEffect?: (effect: string) => Promise<void>): PreviewJobServices {
127
134
  fakeTables(state);
128
135
  const { sql } = state.storage;
129
136
 
@@ -176,11 +183,6 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
176
183
  return row?.sha ?? null;
177
184
  },
178
185
 
179
- async readModelTip(): Promise<ReadModelTip | null> {
180
- const rm = sql.exec<{ sha: string; membership: string; green: number }>(`SELECT * FROM fake_read_model`).toArray()[0];
181
- return rm ? { sha: rm.sha, membership: JSON.parse(rm.membership), green: rm.green === 1 } : null;
182
- },
183
-
184
186
  async materialize({ sha, resume }) {
185
187
  if (await fires("evict-before-build")) evict("before build");
186
188
  const moveTo = sql
@@ -206,6 +208,7 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
206
208
  if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
207
209
  sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
208
210
  await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
211
+ if (file === MATRIX_BUILD_FILE && onEffect) await onEffect("build:file");
209
212
  }
210
213
  return { ok: true, versionId: sha, digest: `digest-${sha}` };
211
214
  },
@@ -271,8 +274,15 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
271
274
  return { outcome: "conflict" as const, notified: true };
272
275
  },
273
276
 
274
- async prepareUndo(): Promise<MergeCandidate> {
275
- throw new Error("prepareUndo is not exercised by the fault harness");
277
+ async prepareUndo({ target }): Promise<MergeCandidate> {
278
+ // The forge opens the undo of the change as a candidate of its own, once per change.
279
+ const of = "prNumber" in target ? `pr-${target.prNumber}` : target.changeId;
280
+ const changeId = `undo-${of}`;
281
+ const headSha = `head-${changeId}`;
282
+ const merged = sql.exec(`SELECT 1 FROM fake_merges WHERE change_id = ?`, changeId).toArray().length > 0;
283
+ if (!merged) sql.exec(`INSERT OR IGNORE INTO fake_open (change_id, head_sha) VALUES (?, ?)`, changeId, headSha);
284
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('undo', ?)`, changeId);
285
+ return { changeId, prNumber: null, headSha, message: `Undo ${of}` };
276
286
  },
277
287
  };
278
288
  }
@@ -33,7 +33,8 @@ type TenantPipelineStub = {
33
33
  | "configureFake"
34
34
  | "fakeState"
35
35
  | "spans"
36
- | "publishHarness"]: TenantPipeline[K];
36
+ | "publishHarness"
37
+ | "matrix"]: TenantPipeline[K];
37
38
  };
38
39
 
39
40
  export const harnessEnv = env as unknown as HarnessEnv;
@@ -0,0 +1,344 @@
1
+ /**
2
+ * The systematic fault matrix's injection points inside the harness object (test-only; the suite is
3
+ * `test/pipeline-faults/`).
4
+ *
5
+ * Every job kind the object runs is wrapped so the object can be evicted (`ctx.abort()`) at any
6
+ * named point of any step:
7
+ *
8
+ * before the step's first execution, before its work
9
+ * after the step's work is done (every effect landed), before the engine records the outcome
10
+ * resume the step's second execution (a step that checkpointed, waited, or was retried)
11
+ * effect right after one named external effect of the step landed (mid-step), before the step
12
+ * goes on — the forge merge, a CI dispatch, a pointer write, a notice, …
13
+ *
14
+ * and a step can be HELD (`hold`): its first execution parks inside the step — renewing its lease —
15
+ * until the test releases it, so "something arrives while this step runs" is a latch, not a race.
16
+ *
17
+ * Effects are reported by the stand-ins themselves (`landed(effect)`): the fake preview services,
18
+ * the publish world, the pointer writer the publish kinds write `live` through, the assets pointer.
19
+ * Each one is logged against the job and step that caused it (`matrix_effects`), so the suite
20
+ * derives a step's mid-step kill points from an unfaulted run, and counts duplicates.
21
+ *
22
+ * A fault is armed for one kind and step; its remaining count is persisted (and flushed) before it
23
+ * acts, so it fires once however often the step is re-run after the eviction it causes. At the kill
24
+ * instant the object records what the convergence guarantee needs (`matrix_kills`): the alarm that
25
+ * will resume the work, whether the job's compensation is armed, and what `live` names next to the
26
+ * engine's live record — a kill that leaves them apart with no compensation armed is a finding.
27
+ */
28
+ import type { DurableObjectState } from "cloudflare:workers";
29
+ import { JobKindRegistry, type JobKindDefinition, type StepContext, type StepDefinition } from "../../src/lib/pipeline/job-runner";
30
+ import type { PreviewJobServices } from "../../src/lib/pipeline/preview-jobs";
31
+ import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
32
+ import { projectionStore, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
33
+
34
+ export type MatrixMode = "before" | "after" | "resume" | "effect" | "hold";
35
+
36
+ export interface MatrixFault {
37
+ kind: string;
38
+ step: string;
39
+ mode: MatrixMode;
40
+ /** `effect` mode: the effect after which the object is evicted. */
41
+ effect?: string | null;
42
+ }
43
+
44
+ /** What the object looked like at the instant a matrix fault evicted it. */
45
+ export interface MatrixKill {
46
+ jobId: string;
47
+ kind: string;
48
+ step: string;
49
+ mode: MatrixMode;
50
+ effect: string | null;
51
+ /** The alarm that resumes the work (null: nothing will). */
52
+ alarm: number | null;
53
+ /** Jobs not yet terminal, and compensations not yet settled. */
54
+ activeJobs: number;
55
+ pendingCompensations: number;
56
+ /** The job's compensation is recorded (armed or pending). */
57
+ armed: boolean;
58
+ /** Publish kinds only: what the `live` channel names, and the engine's live record for the job's provider. */
59
+ live: string | null;
60
+ record: string | null;
61
+ /** CloudFront jobs: what the public host serves (the `live` channel follows proven delivery there). */
62
+ served: string | null;
63
+ }
64
+
65
+ export interface MatrixEffect {
66
+ jobId: string;
67
+ kind: string;
68
+ step: string;
69
+ effect: string;
70
+ }
71
+
72
+ /** Report an external effect that just landed. Evicts the object when a fault is armed for it. */
73
+ export type Landed = (effect: string) => Promise<void>;
74
+
75
+ /**
76
+ * The preview services, reporting each external effect a preview job makes. A refresh that ran into
77
+ * a conflict emails the candidate's author (`email:conflict`); one that did not is `forge:rebase`.
78
+ */
79
+ export function previewServicesWithEffects(services: PreviewJobServices, landed: Landed): PreviewJobServices {
80
+ const after =
81
+ <A extends unknown[], R>(effect: string, call: (...args: A) => Promise<R>) =>
82
+ async (...args: A): Promise<R> => {
83
+ const result = await call(...args);
84
+ await landed(effect);
85
+ return result;
86
+ };
87
+ return {
88
+ ...services,
89
+ integrateCandidate: after("forge:merge", services.integrateCandidate.bind(services)),
90
+ prepareUndo: after("forge:prepare-undo", services.prepareUndo.bind(services)),
91
+ materialize: after("build:materialized", services.materialize.bind(services)),
92
+ reportOutcome: after("mcp:report", services.reportOutcome.bind(services)),
93
+ recordPreviewRefused: after("preview:refused", services.recordPreviewRefused.bind(services)),
94
+ advancePreview: after("preview:advance", services.advancePreview.bind(services)),
95
+ stageAggregate: after("ci:stage-aggregate", services.stageAggregate.bind(services)),
96
+ rebaseCandidate: async (args) => {
97
+ const result = await services.rebaseCandidate(args);
98
+ await landed(result.outcome === "conflict" ? "email:conflict" : "forge:rebase");
99
+ return result;
100
+ },
101
+ };
102
+ }
103
+
104
+ interface Running {
105
+ jobId: string;
106
+ kind: string;
107
+ step: string;
108
+ }
109
+
110
+ function tables(state: DurableObjectState): void {
111
+ const { sql } = state.storage;
112
+ sql.exec(
113
+ `CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER)`,
114
+ );
115
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT)`);
116
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_execs (job_id TEXT, step TEXT, n INTEGER, PRIMARY KEY (job_id, step))`);
117
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_kills (seq INTEGER PRIMARY KEY AUTOINCREMENT, kill TEXT)`);
118
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_holds (job_id TEXT, step TEXT, released INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (job_id, step))`);
119
+ }
120
+
121
+ /** The longest a held step waits for its release before it goes on by itself. */
122
+ const HOLD_LIMIT_MS = 30_000;
123
+
124
+ export class MatrixFaults {
125
+ private running: Running | null = null;
126
+
127
+ constructor(
128
+ private readonly state: DurableObjectState,
129
+ /** The KV the `live` channel is projected to (what the edge reads). */
130
+ private readonly projectionKv: () => TestKv,
131
+ ) {
132
+ tables(state);
133
+ }
134
+
135
+ /** Every kind of `registry`, its steps (and compensation) wrapped with the matrix's kill points. */
136
+ wrap(registry: JobKindRegistry): JobKindRegistry {
137
+ const out = new JobKindRegistry();
138
+ for (const name of registry.list()) {
139
+ const definition = registry.get(name)!;
140
+ const onTerminal = definition.onTerminal;
141
+ out.register({
142
+ ...definition,
143
+ ...(onTerminal
144
+ ? {
145
+ onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
146
+ const previous = this.running;
147
+ this.running = { jobId: info.jobId, kind: name, step: "on-terminal" };
148
+ try {
149
+ await onTerminal(info);
150
+ } finally {
151
+ this.running = previous;
152
+ }
153
+ },
154
+ }
155
+ : {}),
156
+ steps: definition.steps.map((step) => this.wrapStep(name, step)),
157
+ });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ private wrapStep(kind: string, step: StepDefinition<unknown>): StepDefinition<unknown> {
163
+ return {
164
+ ...step,
165
+ run: async (ctx: StepContext<unknown>) => {
166
+ const { sql } = this.state.storage;
167
+ const n = sql
168
+ .exec<{ n: number }>(
169
+ `INSERT INTO matrix_execs (job_id, step, n) VALUES (?, ?, 1) ON CONFLICT(job_id, step) DO UPDATE SET n = n + 1 RETURNING n`,
170
+ ctx.jobId,
171
+ step.name,
172
+ )
173
+ .one().n;
174
+ this.running = { jobId: ctx.jobId, kind, step: step.name };
175
+ try {
176
+ if (n === 1) await this.maybeHold(kind, step.name, ctx);
177
+ if (n === 1) await this.maybeKill("before", null);
178
+ if (n === 2) await this.maybeKill("resume", null);
179
+ const outcome = await step.run(ctx);
180
+ await this.maybeKill("after", null);
181
+ return outcome;
182
+ } finally {
183
+ if (this.running?.jobId === ctx.jobId && this.running.step === step.name) this.running = null;
184
+ }
185
+ },
186
+ };
187
+ }
188
+
189
+ /** The effect hook handed to every stand-in. */
190
+ readonly landed: Landed = async (effect) => {
191
+ const running = this.running;
192
+ this.state.storage.sql.exec(
193
+ `INSERT INTO matrix_effects (job_id, kind, step, effect) VALUES (?, ?, ?, ?)`,
194
+ running?.jobId ?? "",
195
+ running?.kind ?? "",
196
+ running?.step ?? "",
197
+ effect,
198
+ );
199
+ if (running) await this.maybeKill("effect", effect);
200
+ };
201
+
202
+ arm(fault: MatrixFault): void {
203
+ this.state.storage.sql.exec(
204
+ `INSERT INTO matrix_faults (kind, step, mode, effect, remaining) VALUES (?, ?, ?, ?, 1)`,
205
+ fault.kind,
206
+ fault.step,
207
+ fault.mode,
208
+ fault.effect ?? null,
209
+ );
210
+ }
211
+
212
+ /** Steps parked by a `hold` fault and not yet released. */
213
+ held(): { jobId: string; step: string }[] {
214
+ return this.state.storage.sql
215
+ .exec<{ job_id: string; step: string }>(`SELECT job_id, step FROM matrix_holds WHERE released = 0`)
216
+ .toArray()
217
+ .map((r) => ({ jobId: r.job_id, step: r.step }));
218
+ }
219
+
220
+ release(): void {
221
+ this.state.storage.sql.exec(`UPDATE matrix_holds SET released = 1`);
222
+ }
223
+
224
+ private async maybeHold(kind: string, step: string, ctx: StepContext<unknown>): Promise<void> {
225
+ const { sql } = this.state.storage;
226
+ const fault = sql
227
+ .exec<{ id: number }>(`SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = 'hold' AND remaining > 0 ORDER BY id LIMIT 1`, kind, step)
228
+ .toArray()[0];
229
+ if (!fault) return;
230
+ sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
231
+ sql.exec(`INSERT OR REPLACE INTO matrix_holds (job_id, step, released) VALUES (?, ?, 0)`, ctx.jobId, step);
232
+ await this.state.storage.sync();
233
+ const until = Date.now() + HOLD_LIMIT_MS;
234
+ for (;;) {
235
+ const row = sql.exec<{ released: number }>(`SELECT released FROM matrix_holds WHERE job_id = ? AND step = ?`, ctx.jobId, step).one();
236
+ if (row.released === 1 || Date.now() > until) return;
237
+ await new Promise((resolve) => setTimeout(resolve, 10));
238
+ await ctx.renewLease();
239
+ }
240
+ }
241
+
242
+ kills(): MatrixKill[] {
243
+ return this.state.storage.sql
244
+ .exec<{ kill: string }>(`SELECT kill FROM matrix_kills ORDER BY seq`)
245
+ .toArray()
246
+ .map((r) => JSON.parse(r.kill) as MatrixKill);
247
+ }
248
+
249
+ effects(jobId?: string): MatrixEffect[] {
250
+ const { sql } = this.state.storage;
251
+ const rows = jobId
252
+ ? sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(
253
+ `SELECT job_id, kind, step, effect FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
254
+ jobId,
255
+ )
256
+ : sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(`SELECT job_id, kind, step, effect FROM matrix_effects ORDER BY seq`);
257
+ return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect }));
258
+ }
259
+
260
+ execs(jobId: string): { step: string; n: number }[] {
261
+ return this.state.storage.sql.exec<{ step: string; n: number }>(`SELECT step, n FROM matrix_execs WHERE job_id = ?`, jobId).toArray();
262
+ }
263
+
264
+ /** Non-terminal jobs, unsettled compensations and the alarm: an orphan is work no alarm will resume. */
265
+ async health(): Promise<{
266
+ alarm: number | null;
267
+ active: { jobId: string; state: string }[];
268
+ compensations: { jobId: string; exhausted: boolean; reported: boolean }[];
269
+ }> {
270
+ const { sql } = this.state.storage;
271
+ const active = sql
272
+ .exec<{ job_id: string; state: string }>(`SELECT job_id, state FROM jobs WHERE state NOT IN ('succeeded', 'needs-developer', 'stuck')`)
273
+ .toArray()
274
+ .map((r) => ({ jobId: r.job_id, state: r.state }));
275
+ const compensations = sql
276
+ .exec<{ job_id: string; exhausted_at: number | null; reported_at: number | null }>(
277
+ `SELECT job_id, exhausted_at, reported_at FROM terminal_hooks`,
278
+ )
279
+ .toArray()
280
+ .map((r) => ({ jobId: r.job_id, exhausted: r.exhausted_at !== null, reported: r.reported_at !== null }));
281
+ return { alarm: await this.state.storage.getAlarm(), active, compensations };
282
+ }
283
+
284
+ private async maybeKill(mode: MatrixMode, effect: string | null): Promise<void> {
285
+ const running = this.running;
286
+ if (!running) return;
287
+ const { sql } = this.state.storage;
288
+ const fault = sql
289
+ .exec<{ id: number }>(
290
+ `SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
291
+ running.kind,
292
+ running.step,
293
+ mode,
294
+ effect ?? "",
295
+ )
296
+ .toArray()[0];
297
+ if (!fault) return;
298
+ sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
299
+ const kill = await this.snapshot(running, mode, effect);
300
+ sql.exec(`INSERT INTO matrix_kills (kill) VALUES (?)`, JSON.stringify(kill));
301
+ // Everything written so far — the effect itself included — is durable, exactly as a real crash
302
+ // after it would leave it.
303
+ await this.state.storage.sync();
304
+ this.state.abort(`synthetic eviction matrix ${mode} ${running.step}${effect ? ` ${effect}` : ""}`);
305
+ throw new Error("abort() returned");
306
+ }
307
+
308
+ private async snapshot(running: Running, mode: MatrixMode, effect: string | null): Promise<MatrixKill> {
309
+ const { sql } = this.state.storage;
310
+ const job = sql
311
+ .exec<{ tenant_id: string; input: string; lane: string }>(`SELECT tenant_id, input, lane FROM jobs WHERE job_id = ?`, running.jobId)
312
+ .toArray()[0];
313
+ const health = await this.health();
314
+ let live: string | null = null;
315
+ let record: string | null = null;
316
+ let served: string | null = null;
317
+ // Publish kinds (their input declares the serving provider) are the ones that move `live`.
318
+ const input = job ? (JSON.parse(job.input) as { provider?: string | null }) : null;
319
+ if (job?.lane === "live" && input && "provider" in input) {
320
+ const provider = input.provider ?? "worker-r2-pointer";
321
+ const kv = this.projectionKv();
322
+ live = (await readLivePointer(projectionStore(kv), job.tenant_id)).versionId;
323
+ record = sql.exec<{ sha: string }>(`SELECT sha FROM live WHERE provider = ?`, provider).toArray()[0]?.sha ?? null;
324
+ if (provider === "s3-overwrite") {
325
+ const raw = await kv.get("world:cloudfront");
326
+ served = raw ? ((JSON.parse(raw) as { sha?: string }).sha ?? null) : null;
327
+ }
328
+ }
329
+ return {
330
+ jobId: running.jobId,
331
+ kind: running.kind,
332
+ step: running.step,
333
+ mode,
334
+ effect,
335
+ alarm: health.alarm,
336
+ activeJobs: health.active.length,
337
+ pendingCompensations: health.compensations.length,
338
+ armed: health.compensations.some((c) => c.jobId === running.jobId),
339
+ live,
340
+ record,
341
+ served,
342
+ };
343
+ }
344
+ }