@tokenoftrust/storefront-runner 2.4.7 → 2.4.9

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.
@@ -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",
@@ -17,16 +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,
26
+ import {
27
+ BUILD_CHECKPOINT_EVERY,
28
+ type MembershipEntry,
29
+ type MergeCandidate,
30
+ type PreviewJobServices,
30
31
  } from "../../src/lib/pipeline/preview-jobs";
31
32
 
32
33
  /** Files a fake build materializes. */
@@ -122,7 +123,14 @@ export function readFakeState(state: DurableObjectState): FakeState {
122
123
  };
123
124
  }
124
125
 
125
- 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 {
126
134
  fakeTables(state);
127
135
  const { sql } = state.storage;
128
136
 
@@ -200,6 +208,7 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
200
208
  if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
201
209
  sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
202
210
  await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
211
+ if (file === MATRIX_BUILD_FILE && onEffect) await onEffect("build:file");
203
212
  }
204
213
  return { ok: true, versionId: sha, digest: `digest-${sha}` };
205
214
  },
@@ -265,8 +274,15 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
265
274
  return { outcome: "conflict" as const, notified: true };
266
275
  },
267
276
 
268
- async prepareUndo(): Promise<MergeCandidate> {
269
- 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}` };
270
286
  },
271
287
  };
272
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
+ }
@@ -4,21 +4,30 @@
4
4
  * restarts the object without touching its callers). It runs the production engine with the
5
5
  * synthetic job kinds, the production preview kinds (integrate / rebuild / revert / sync-preview) over
6
6
  * fake services (`./fake-preview-services.ts`) and the real publish kinds over the stand-in world
7
- * (`./publish-kinds.ts`), and answers intents through `submitIntent`.
7
+ * (`./publish-kinds.ts`), and answers intents through `submitIntent`. Every kind is wrapped with the
8
+ * fault matrix's kill points (`./matrix-faults.ts`), inert until a test arms one.
8
9
  */
9
10
  import type { DurableObjectState } from "cloudflare:workers";
10
11
  import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
11
12
  import { submitIntent, tenantPipeline, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
12
13
  import { submitPreviewSync } from "../../src/lib/pipeline/preview-sync-intake";
13
- import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
14
+ import { JobKindRegistry, StaleExecutionError, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
14
15
  import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
15
16
  import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
16
17
  import { configureFake, fakePreviewServices, readFakeState, type FakeFault, type FakeState } from "./fake-preview-services";
18
+ import { MatrixFaults, previewServicesWithEffects, type Landed, type MatrixFault } from "./matrix-faults";
19
+ import { kvAbandonServices, legacyOperationKey, registerSeedJobKind, type SeedServices } from "../../src/lib/pipeline/seed-from-legacy";
20
+ import { readAssetsPointer } from "@tot/public-runtime";
17
21
  import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
18
- import { listReleaseReceipts, readReleaseTruth } from "../../src/lib/change/store/aggregate-release-store";
22
+ import {
23
+ listIntegrationRuns,
24
+ listReleaseReceipts,
25
+ readReleaseTruth,
26
+ writeIntegrationRun,
27
+ type ReleaseModelKv,
28
+ } from "../../src/lib/change/store/aggregate-release-store";
19
29
  import { projectionStore, seedGreenAggregate, testVersionStore, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
20
30
  import {
21
- armedAtFaults,
22
31
  armPublishFault,
23
32
  firedPublishFaults,
24
33
  publishWorldOf,
@@ -27,6 +36,9 @@ import {
27
36
  } from "./publish-kinds";
28
37
 
29
38
  /** The preview kinds' timing in the harness: the production shape, scaled to milliseconds. */
39
+ /** The seed kind's lease and retries in the harness. */
40
+ const HARNESS_SEED_TIMING = { leaseMs: 1_000, deadlineMs: 30_000, retryBaseMs: 10 };
41
+
30
42
  export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
31
43
  leaseMs: 1_000,
32
44
  deadlineMs: 30_000,
@@ -35,13 +47,133 @@ export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
35
47
  defaultExpectedMs: 2_000,
36
48
  };
37
49
 
50
+ /**
51
+ * The seed's services in the harness: what is served, read from this object's pointer authority (as
52
+ * the deployed object reads it), and the close-out of legacy records over the harness KV — each
53
+ * close-out reported to the fault matrix.
54
+ */
55
+ function harnessSeedServices(
56
+ ctx: DurableObjectState,
57
+ pointers: { read(tenantId: string): Promise<{ channels?: { preview?: string; live?: string } }> },
58
+ landed: Landed,
59
+ ): SeedServices {
60
+ const abandon = kvAbandonServices(publishWorldOf(ctx).kv as unknown as ReleaseModelKv);
61
+ return {
62
+ async pointers(tenantId) {
63
+ const pointer = await pointers.read(tenantId);
64
+ return { preview: pointer.channels?.preview ?? null, live: pointer.channels?.live ?? null };
65
+ },
66
+ async abandonRun(tenantId, runId, at) {
67
+ const changed = await abandon.abandonRun(tenantId, runId, at);
68
+ await landed("kv:abandon-run");
69
+ return changed;
70
+ },
71
+ async abandonOp(tenantId, op, at) {
72
+ const changed = await abandon.abandonOp(tenantId, op, at);
73
+ await landed("kv:abandon-op");
74
+ return changed;
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Per object: the fault matrix's injection points and the (wrapped) registry it runs. */
80
+ const matrices = new WeakMap<DurableObjectState, { faults: MatrixFaults; registry: JobKindRegistry }>();
81
+
38
82
  export class TenantPipeline extends DeployedTenantPipeline {
39
83
  protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
40
- return registerHarnessPublishKinds(
84
+ const faults = new MatrixFaults(ctx, () => publishWorldOf(ctx).kv);
85
+ const services = previewServicesWithEffects(fakePreviewServices(ctx, faults.landed), faults.landed);
86
+ const registry = registerHarnessPublishKinds(
41
87
  ctx,
42
- registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING),
88
+ registerPreviewJobKinds(syntheticJobKinds(ctx), services, HARNESS_PREVIEW_TIMING),
43
89
  this.pointers,
90
+ faults.landed,
44
91
  );
92
+ // The production seed kind, its lease and retries scaled to the harness (an evicted seed resumes
93
+ // when its lease runs out, as in production).
94
+ const seed = registerSeedJobKind(new JobKindRegistry(), harnessSeedServices(ctx, this.pointers, faults.landed));
95
+ for (const name of seed.list()) registry.register({ ...seed.get(name)!, ...HARNESS_SEED_TIMING });
96
+ const wrapped = faults.wrap(registry);
97
+ matrices.set(ctx, { faults, registry: wrapped });
98
+ return wrapped;
99
+ }
100
+
101
+ /** Harness-only: arm and read the fault matrix (`./matrix-faults.ts`, `test/pipeline-faults/`). */
102
+ async matrix(op: string, args: Record<string, unknown> = {}): Promise<unknown> {
103
+ const { faults, registry } = matrices.get(this.ctx)!;
104
+ switch (op) {
105
+ case "arm":
106
+ faults.arm(args as unknown as MatrixFault);
107
+ return null;
108
+ case "held":
109
+ return faults.held();
110
+ case "release":
111
+ faults.release();
112
+ return null;
113
+ case "kills":
114
+ return faults.kills();
115
+ case "effects":
116
+ return faults.effects(args.jobId as string | undefined);
117
+ case "execs":
118
+ return faults.execs(String(args.jobId));
119
+ case "health":
120
+ return faults.health();
121
+ case "kinds":
122
+ return registry.list().map((kind) => {
123
+ const definition = registry.get(kind)!;
124
+ return { kind, lane: definition.lane ?? "live", steps: definition.steps.map((s) => s.name) };
125
+ });
126
+ case "endState":
127
+ return this.endState(String(args.tenantId));
128
+ default:
129
+ throw new Error(`unknown matrix op ${op}`);
130
+ }
131
+ }
132
+
133
+ /** What a converged job leaves behind, on both lanes — the fault matrix compares it to an unfaulted run. */
134
+ private async endState(tenantId: string): Promise<Record<string, unknown>> {
135
+ const { kv, world } = publishWorldOf(this.ctx);
136
+ const truth = await readReleaseTruth(kv, tenantId, "preview");
137
+ const fake = readFakeState(this.ctx);
138
+ return {
139
+ live: await readLivePointer(projectionStore(kv), tenantId),
140
+ liveRecords: this.ctx.storage.sql
141
+ .exec<{ provider: string; sha: string; receipt_id: string }>(`SELECT provider, sha, receipt_id FROM live ORDER BY provider`)
142
+ .toArray()
143
+ .map((r) => ({ provider: r.provider, sha: r.sha, receiptId: r.receipt_id })),
144
+ truth: truth
145
+ ? {
146
+ liveReceiptId: truth.liveReceiptId ?? null,
147
+ pendingReceiptId: truth.pendingReceiptId ?? null,
148
+ deliveryState: truth.deliveryState ?? null,
149
+ unprovenLive: truth.unprovenLive ?? null,
150
+ liveDisagrees: truth.liveDisagrees ? { serving: truth.liveDisagrees.serving, recordedReceiptId: truth.liveDisagrees.recordedReceiptId } : null,
151
+ }
152
+ : null,
153
+ receipts: (await listReleaseReceipts(kv, tenantId, "preview")).map((r) => ({ receiptId: r.receiptId, sha: r.aggregateSha })),
154
+ promotion: await world.promotionStatus(),
155
+ cloudfront: await world.cloudFrontServes(),
156
+ assetsLive: (await readAssetsPointer({ get: (k: string) => kv.get(k) }, tenantId)).live ?? null,
157
+ legacy: {
158
+ runs: (await listIntegrationRuns(kv as unknown as ReleaseModelKv, tenantId, "preview")).map((r) => ({ runId: r.runId, state: r.state })),
159
+ op: await (async () => {
160
+ const raw = await kv.get(legacyOperationKey(tenantId, { operation: "publication", subjectId: "op-stale" }));
161
+ return raw ? ((JSON.parse(raw) as { problem?: { code?: string } | null }).problem?.code ?? "open") : null;
162
+ })(),
163
+ },
164
+ preview: {
165
+ pointer: fake.previewPointer,
166
+ readModel: fake.readModel,
167
+ merges: fake.merges.map((m) => ({ changeId: m.changeId, sha: m.sha })),
168
+ commits: this.ctx.storage.sql
169
+ .exec<{ sha: string; membership: string }>(`SELECT sha, membership FROM commits ORDER BY sha`)
170
+ .toArray()
171
+ .map((r) => ({ sha: r.sha, membership: JSON.parse(r.membership) as unknown })),
172
+ evidence: this.ctx.storage.sql
173
+ .exec<{ sha: string; digest: string }>(`SELECT sha, digest FROM evidence ORDER BY sha`)
174
+ .toArray(),
175
+ },
176
+ };
45
177
  }
46
178
 
47
179
  /**
@@ -123,13 +255,46 @@ export class TenantPipeline extends DeployedTenantPipeline {
123
255
  Number(args.times ?? 1),
124
256
  );
125
257
  return null;
258
+ case "seedPointers":
259
+ // What the shared preview and the live site are served from, as a legacy store left them.
260
+ await this.pointers.update(tenantId, (current) => ({
261
+ ...current,
262
+ channels: { ...current.channels, preview: String(args.preview), live: String(args.live) },
263
+ channelRevs: { ...current.channelRevs, preview: (current.channelRevs?.preview ?? 0) + 1, live: (current.channelRevs?.live ?? 0) + 1 },
264
+ }));
265
+ return null;
266
+ case "seedLegacy": {
267
+ // A request-scoped run and an operation that never finished.
268
+ const at = "2026-10-09T00:00:00.000Z";
269
+ await writeIntegrationRun(
270
+ kv as unknown as ReleaseModelKv,
271
+ {
272
+ tenantId,
273
+ aggregateId: "preview",
274
+ runId: String(args.runId),
275
+ aggregateSha: String(args.sha),
276
+ includedPrs: [],
277
+ state: "running",
278
+ previousRunId: null,
279
+ artifactDigest: null,
280
+ startedAt: at,
281
+ finishedAt: null,
282
+ statusMessage: null,
283
+ },
284
+ 0,
285
+ );
286
+ const op = { operation: "publication" as const, subjectId: String(args.opSubject) };
287
+ await kv.put(
288
+ legacyOperationKey(tenantId, op),
289
+ JSON.stringify({ tenantId, ...op, revision: String(args.sha), stage: "publishing", problem: null, attemptsMade: 1, updatedAt: at, rev: 0 }),
290
+ );
291
+ return null;
292
+ }
126
293
  case "armFault":
127
294
  armPublishFault(this.ctx, args as unknown as PublishFault);
128
295
  return null;
129
296
  case "firedFaults":
130
297
  return firedPublishFaults(this.ctx);
131
- case "armedAtFaults":
132
- return armedAtFaults(this.ctx);
133
298
  default:
134
299
  throw new Error(`unknown publish harness op ${op}`);
135
300
  }