@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.
@@ -0,0 +1,661 @@
1
+ /**
2
+ * The fault matrix's scenarios, driver and convergence assertions (the cells are in
3
+ * `./fault-matrix.test.ts`; the kill points inside the object are `../pipeline/matrix-faults.ts`).
4
+ *
5
+ * A SCENARIO is one job kind on one provider, from a fresh tenant: what the world holds before the
6
+ * job (`setup`, which may run prerequisite jobs unfaulted) and the intent that starts it. The preview
7
+ * kinds take no provider — the shared preview is Worker-served for Cloudflare and CloudFront tenants
8
+ * alike, so they run once; every live-lane kind runs on both.
9
+ *
10
+ * A CELL runs a scenario with one fault armed and drives the job to its end the way the world would:
11
+ * a CI dispatch is completed and its callback delivered, a hotfix's static prebuild is staged and
12
+ * reported. The cell's outcome is then held against the scenario's unfaulted run (`baselineOf`).
13
+ */
14
+ import type { JobView, TransitionView } from "../../src/lib/pipeline/job-runner";
15
+ import type { MatrixEffect, MatrixFault, MatrixKill } from "../pipeline/matrix-faults";
16
+ import type { CiDispatch } from "../../src/lib/pipeline/publish/publish-test-support";
17
+ import { pipelineFor, uniqueTenant } from "../pipeline/harness";
18
+
19
+ export type Provider = "worker-r2-pointer" | "s3-overwrite";
20
+
21
+ export const PROVIDER_NAME: Record<Provider, string> = { "worker-r2-pointer": "cloudflare", "s3-overwrite": "cloudfront" };
22
+
23
+ const TERMINAL = new Set(["succeeded", "needs-developer", "stuck"]);
24
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ type Stub = ReturnType<typeof pipelineFor>;
27
+
28
+ /** One tenant's object, called the way a real caller would: a fresh stub per call, retried while it restarts. */
29
+ export class Tenant {
30
+ /** The shas staged for the pointer plane so far (the world's staged table is replaced whole). */
31
+ readonly staged: string[] = [];
32
+
33
+ constructor(readonly id: string) {}
34
+
35
+ async call<T>(fn: (stub: Stub) => Promise<T>): Promise<T> {
36
+ for (let i = 0; ; i += 1) {
37
+ try {
38
+ return await fn(pipelineFor(this.id));
39
+ } catch (err) {
40
+ if (i > 400 || !/synthetic eviction|abort/i.test(String((err as Error).message))) throw err;
41
+ await sleep(25);
42
+ }
43
+ }
44
+ }
45
+
46
+ harness<T = unknown>(op: string, args: Record<string, unknown> = {}): Promise<T> {
47
+ return this.call((stub) => stub.publishHarness(op, { tenantId: this.id, ...args })) as Promise<T>;
48
+ }
49
+
50
+ matrix<T = unknown>(op: string, args: Record<string, unknown> = {}): Promise<T> {
51
+ return this.call((stub) => stub.matrix(op, args)) as Promise<T>;
52
+ }
53
+
54
+ async submit(kind: string, input: unknown): Promise<string> {
55
+ const result = await this.call((stub) =>
56
+ stub.submit(this.id, { kind, input, actor: { type: "test", id: "fault-matrix" }, admissionDecisionId: "decision-matrix" }),
57
+ );
58
+ if (!result.ok) throw new Error(`${kind} refused at intake: ${result.reason}`);
59
+ return result.jobId;
60
+ }
61
+
62
+ job(jobId: string): Promise<JobView | null> {
63
+ return this.call((stub) => stub.job(jobId));
64
+ }
65
+
66
+ transitions(jobId?: string): Promise<TransitionView[]> {
67
+ return this.call((stub) => stub.transitions(jobId, 2_000));
68
+ }
69
+
70
+ deliver(correlationId: string, payload: unknown) {
71
+ return this.call((stub) => stub.deliverExternal(correlationId, payload));
72
+ }
73
+ }
74
+
75
+ // ── Scenarios ─────────────────────────────────────────────────────────────────
76
+
77
+ export interface Scenario {
78
+ /** `<kind> × <provider>` (or `<kind>` for a provider-neutral preview kind). */
79
+ id: string;
80
+ kind: string;
81
+ provider: Provider | null;
82
+ lane: "live" | "preview";
83
+ setup(t: Tenant): Promise<void>;
84
+ intent(t: Tenant): { kind: string; input: unknown };
85
+ /** How many times the intent is delivered (a duplicate delivery coalesces). Default 1. */
86
+ deliveries?: number;
87
+ }
88
+
89
+ export const REPO = "shop.example";
90
+
91
+ const AGGREGATE = { kind: "aggregate", expectedAggregateSha: null, expectedArtifactDigest: null };
92
+
93
+ export function publishInput(provider: Provider, target: unknown) {
94
+ return {
95
+ aggregateId: "preview",
96
+ repo: REPO,
97
+ provider,
98
+ staticPublishTarget: "www-test",
99
+ publicManifestUrl: "https://shop.test/manifest.json",
100
+ storefrontMode: "enabled",
101
+ workerHost: "storefront.test",
102
+ target,
103
+ };
104
+ }
105
+
106
+ const HOTFIX_TARGET = {
107
+ kind: "hotfix",
108
+ changeId: "fix-9",
109
+ prNumber: 9,
110
+ headSha: "head-fix-9",
111
+ expectedHeadSha: null,
112
+ expectedArtifactDigest: null,
113
+ message: null,
114
+ };
115
+
116
+ /** A green aggregate at `sha` (with an assets snapshot), staged for the pointer plane on Cloudflare. */
117
+ async function seedRelease(t: Tenant, provider: Provider, sha: string, runId: string): Promise<void> {
118
+ await t.harness("seedGreen", { sha, runId, prs: [{ changeId: `c-${sha}`, prNumber: 1, headSha: `h-${sha}` }], assets: true });
119
+ if (provider === "worker-r2-pointer") {
120
+ // The world's staged table is replaced whole: every sha staged so far stays staged.
121
+ t.staged.push(sha);
122
+ await t.harness("configure", { patch: { staged: Object.fromEntries(t.staged.map((s) => [s, { digest: `digest-${s}`, validated: true }])) } });
123
+ }
124
+ }
125
+
126
+ /**
127
+ * A CloudFront tenant's public host already serves an earlier release (a re-dispatch proves it is not
128
+ * the new one), and GitHub lists a dispatched run within a second — well inside the CI window, as in
129
+ * production (seconds against 45 minutes).
130
+ */
131
+ async function cloudFrontServesEarlier(t: Tenant, provider: Provider): Promise<void> {
132
+ if (provider !== "s3-overwrite") return;
133
+ await t.harness("serveOnCloudFront", { sha: "sha-0", treeDigest: "tree-0" });
134
+ await t.harness("configure", { patch: { runListingDelayMs: 1_000 } });
135
+ }
136
+
137
+ /** Run a prerequisite job unfaulted to `succeeded`. */
138
+ async function prerequisite(t: Tenant, kind: string, input: unknown): Promise<void> {
139
+ const jobId = await t.submit(kind, input);
140
+ const { job } = await drive(t, jobId);
141
+ if (job.state !== "succeeded") throw new Error(`setup ${kind} ended ${job.state}: ${JSON.stringify(job.error)}`);
142
+ }
143
+
144
+ async function goLive(t: Tenant, provider: Provider, sha: string, runId: string): Promise<void> {
145
+ await seedRelease(t, provider, sha, runId);
146
+ await prerequisite(t, "publish", publishInput(provider, AGGREGATE));
147
+ }
148
+
149
+ const integrateInput = (changeId: string, pr: number) => ({ repo: REPO, changeId, prNumber: pr, headSha: `head-${changeId}` });
150
+
151
+ async function integratedPr2(t: Tenant): Promise<void> {
152
+ await t.call((stub) => stub.configureFake({ open: [{ changeId: "pr-2", headSha: "head-pr-2" }] }));
153
+ await prerequisite(t, "integrate", integrateInput("pr-2", 2));
154
+ }
155
+
156
+ export const PREVIEW_SCENARIOS: Scenario[] = [
157
+ {
158
+ id: "integrate",
159
+ kind: "integrate",
160
+ provider: null,
161
+ lane: "preview",
162
+ // Two other open candidates: one rebases clean, one conflicts and emails its author.
163
+ setup: (t) =>
164
+ t.call((stub) =>
165
+ stub.configureFake({
166
+ open: [{ changeId: "pr-2", headSha: "head-pr-2" }],
167
+ rebaseCandidates: [
168
+ { changeId: "pr-5", pr: 5, conflicts: true },
169
+ { changeId: "pr-6", pr: 6 },
170
+ ],
171
+ } as never),
172
+ ),
173
+ intent: () => ({ kind: "integrate", input: integrateInput("pr-2", 2) }),
174
+ },
175
+ { id: "rebuild", kind: "rebuild", provider: null, lane: "preview", setup: integratedPr2, intent: () => ({ kind: "rebuild", input: { repo: REPO } }) },
176
+ {
177
+ id: "revert",
178
+ kind: "revert",
179
+ provider: null,
180
+ lane: "preview",
181
+ setup: integratedPr2,
182
+ intent: () => ({ kind: "revert", input: { repo: REPO, target: { prNumber: 2 } } }),
183
+ },
184
+ {
185
+ id: "sync-preview",
186
+ kind: "sync-preview",
187
+ provider: null,
188
+ lane: "preview",
189
+ setup: async () => {},
190
+ intent: () => ({ kind: "sync-preview", input: { repo: REPO, sha: "c-1", source: "push" } }),
191
+ // The same push forwarded twice (two forwarders, or a redelivery): one job.
192
+ deliveries: 2,
193
+ },
194
+ ];
195
+
196
+ function liveScenarios(provider: Provider): Scenario[] {
197
+ const name = PROVIDER_NAME[provider];
198
+ return [
199
+ {
200
+ id: `publish × ${name}`,
201
+ kind: "publish",
202
+ provider,
203
+ lane: "live",
204
+ setup: async (t) => {
205
+ await cloudFrontServesEarlier(t, provider);
206
+ await seedRelease(t, provider, "sha-1", "run-1");
207
+ },
208
+ intent: () => ({ kind: "publish", input: publishInput(provider, AGGREGATE) }),
209
+ },
210
+ {
211
+ id: `rollback × ${name}`,
212
+ kind: "rollback",
213
+ provider,
214
+ lane: "live",
215
+ setup: async (t) => {
216
+ await cloudFrontServesEarlier(t, provider);
217
+ await goLive(t, provider, "sha-1", "run-1");
218
+ await goLive(t, provider, "sha-2", "run-2");
219
+ },
220
+ intent: (t) => ({ kind: "rollback", input: publishInput(provider, { kind: "release", receiptId: `rcpt:${t.id}:preview:run-1`, versionId: null }) }),
221
+ },
222
+ {
223
+ id: `hotfix × ${name}`,
224
+ kind: "hotfix",
225
+ provider,
226
+ lane: "live",
227
+ setup: async (t) => {
228
+ await cloudFrontServesEarlier(t, provider);
229
+ await t.harness("configure", { patch: { hotfixMainSha: "main-9", hotfixAssets: true } });
230
+ },
231
+ intent: () => ({ kind: "hotfix", input: publishInput(provider, HOTFIX_TARGET) }),
232
+ },
233
+ ];
234
+ }
235
+
236
+ export const LIVE_SCENARIOS: Scenario[] = [...liveScenarios("worker-r2-pointer"), ...liveScenarios("s3-overwrite")];
237
+
238
+ const SEED_PREVIEW = "a".repeat(40);
239
+ const SEED_LIVE = "b".repeat(40);
240
+
241
+ /**
242
+ * `seed-from-legacy`: a store the legacy records left serving a preview tip and a live release,
243
+ * with a run and a publication that never finished. The seed records the pipeline's first facts
244
+ * for the provider the store serves on, and closes both records out.
245
+ */
246
+ function seedScenario(provider: Provider): Scenario {
247
+ return {
248
+ id: `seed-from-legacy × ${PROVIDER_NAME[provider]}`,
249
+ kind: "seed-from-legacy",
250
+ provider,
251
+ lane: "live",
252
+ setup: async (t) => {
253
+ await t.harness("seedPointers", { preview: SEED_PREVIEW, live: SEED_LIVE });
254
+ await t.harness("seedLegacy", { runId: "run-stale", sha: SEED_PREVIEW, opSubject: "op-stale" });
255
+ },
256
+ intent: (t) => ({
257
+ kind: "seed-from-legacy",
258
+ input: {
259
+ tenantId: t.id,
260
+ preview: {
261
+ sha: SEED_PREVIEW,
262
+ membership: [{ pr: 2, changeId: "pr-2", headSha: "head-pr-2" }],
263
+ evidence: { digest: `digest-${SEED_PREVIEW}`, promotable: true },
264
+ },
265
+ live: { provider, sha: SEED_LIVE, receiptId: "rcpt-seeded", provedAt: null },
266
+ abandon: { runs: ["run-stale"], ops: [{ operation: "publication", subjectId: "op-stale" }] },
267
+ expect: { previewPointer: SEED_PREVIEW, livePointer: SEED_LIVE },
268
+ },
269
+ }),
270
+ };
271
+ }
272
+
273
+ export const SEED_SCENARIOS: Scenario[] = [seedScenario("worker-r2-pointer"), seedScenario("s3-overwrite")];
274
+
275
+ export const SCENARIOS: Scenario[] = [...PREVIEW_SCENARIOS, ...LIVE_SCENARIOS, ...SEED_SCENARIOS];
276
+
277
+ // ── The driver ────────────────────────────────────────────────────────────────
278
+
279
+ /** How the CI answers a publish/rollback dispatch (a hotfix's static prebuild is always staged and reported). */
280
+ export type CallbackMode = "deliver" | "never" | "twice" | "superseded" | "cancel-first";
281
+
282
+ export interface DriveNotes {
283
+ /** Every answer to `deliverExternal`, in order, with the id it named. */
284
+ deliveries: { correlationId: string; ok: boolean; duplicate: boolean | null; jobId: string | null }[];
285
+ /** `superseded`: the job's state right after the stale callback was refused. */
286
+ stateAfterStale: string | null;
287
+ /** `cancel-first`: the cancelled dispatch, and the answer to its late callback. */
288
+ cancelled: string | null;
289
+ lateCallback: { ok: boolean } | null;
290
+ }
291
+
292
+ export const success = (sha: string, digest: string | null) => ({
293
+ status: "succeeded",
294
+ deliveredSourceSha: sha,
295
+ deliveredTreeDigest: digest,
296
+ runUrl: null,
297
+ error: null,
298
+ findings: [],
299
+ });
300
+
301
+ /**
302
+ * Drive `jobId` to a terminal state, answering every external wait as the world would. Returns the
303
+ * terminal job. `callbacks` shapes the answer to the FIRST publish/rollback CI dispatch only;
304
+ * `handled` names waits the caller already answered.
305
+ */
306
+ export async function drive(
307
+ t: Tenant,
308
+ jobId: string,
309
+ opts: { callbacks?: CallbackMode; timeoutMs?: number; handled?: string[] } = {},
310
+ ): Promise<{ job: JobView; notes: DriveNotes }> {
311
+ const mode = opts.callbacks ?? "deliver";
312
+ const notes: DriveNotes = { deliveries: [], stateAfterStale: null, cancelled: null, lateCallback: null };
313
+ /** Waits already answered (by the caller, or earlier in this drive). */
314
+ const handled = new Set<string>(opts.handled ?? []);
315
+ let shaped = false;
316
+ const deadline = Date.now() + (opts.timeoutMs ?? 45_000);
317
+ const deliver = async (id: string, payload: unknown) => {
318
+ const r = await t.deliver(id, payload);
319
+ notes.deliveries.push({ correlationId: id, ok: r.ok, duplicate: r.ok ? r.duplicate : null, jobId: r.ok ? r.jobId : null });
320
+ return r;
321
+ };
322
+ for (;;) {
323
+ const job = await t.job(jobId);
324
+ if (!job) throw new Error(`job ${jobId} vanished`);
325
+ if (TERMINAL.has(job.state)) return { job, notes };
326
+ if (Date.now() > deadline) throw new Error(`job ${jobId} (${job.kind}) not terminal: ${JSON.stringify([job.step, job.state, job.attempt])}`);
327
+ const waitingOn = job.state === "awaiting-external" ? job.awaiting : null;
328
+ if (waitingOn && !handled.has(waitingOn)) {
329
+ const dispatch = (await t.harness<CiDispatch[]>("dispatches")).find((d) => d.dispatchId === waitingOn);
330
+ if (dispatch) {
331
+ handled.add(waitingOn);
332
+ if (dispatch.workflow === "www-candidate-prebuild") {
333
+ const digest = `digest-${dispatch.versionId}`;
334
+ await t.harness("completeStage", { dispatchId: waitingOn, conclusion: "success", digest });
335
+ await deliver(waitingOn, success(dispatch.versionId, digest));
336
+ } else {
337
+ const tree = `tree-${dispatch.versionId}`;
338
+ const shape = shaped ? "deliver" : mode;
339
+ shaped = true;
340
+ if (shape === "cancel-first") {
341
+ notes.cancelled = waitingOn;
342
+ await t.harness("completeRun", { dispatchId: waitingOn, conclusion: "cancelled" });
343
+ } else {
344
+ if (notes.cancelled) {
345
+ // The cancelled run's callback arrives late, after the job re-dispatched: refused.
346
+ const late = await t.deliver(notes.cancelled, success(dispatch.versionId, tree));
347
+ notes.lateCallback = { ok: late.ok };
348
+ }
349
+ await t.harness("completeRun", { dispatchId: waitingOn, conclusion: "success", served: { treeDigest: tree } });
350
+ if (shape === "superseded") {
351
+ await deliver(`dsp:superseded:${waitingOn}`, success(dispatch.versionId, tree));
352
+ notes.stateAfterStale = (await t.job(jobId))?.state ?? null;
353
+ }
354
+ if (shape !== "never") await deliver(waitingOn, success(dispatch.versionId, tree));
355
+ if (shape === "twice") await deliver(waitingOn, success(dispatch.versionId, tree));
356
+ }
357
+ }
358
+ }
359
+ }
360
+ await sleep(30);
361
+ }
362
+ }
363
+
364
+ // ── A cell ────────────────────────────────────────────────────────────────────
365
+
366
+ export interface EndState {
367
+ live: { versionId: string | null; treeDigest: string | null };
368
+ liveRecords: { provider: string; sha: string; receiptId: string }[];
369
+ truth: {
370
+ liveReceiptId: string | null;
371
+ pendingReceiptId: string | null;
372
+ deliveryState: string | null;
373
+ unprovenLive: unknown;
374
+ liveDisagrees: unknown;
375
+ } | null;
376
+ receipts: { receiptId: string; sha: string }[];
377
+ promotion: { versionId: string; digest: string } | null;
378
+ cloudfront: { sha: string; treeDigest: string | null } | null;
379
+ assetsLive: string | null;
380
+ /** The request-scoped records a seed closes out. */
381
+ legacy: { runs: { runId: string; state: string }[]; op: string | null };
382
+ preview: Record<string, unknown>;
383
+ }
384
+
385
+ export interface Health {
386
+ alarm: number | null;
387
+ active: { jobId: string; state: string }[];
388
+ compensations: { jobId: string; exhausted: boolean; reported: boolean }[];
389
+ }
390
+
391
+ export interface CellRun {
392
+ tenant: Tenant;
393
+ jobId: string;
394
+ jobIds: string[];
395
+ job: JobView;
396
+ notes: DriveNotes;
397
+ log: TransitionView[];
398
+ all: TransitionView[];
399
+ kills: MatrixKill[];
400
+ effects: MatrixEffect[];
401
+ execs: { step: string; n: number }[];
402
+ dispatches: CiDispatch[];
403
+ end: EndState;
404
+ health: Health;
405
+ }
406
+
407
+ export interface CellSpec {
408
+ scenario: Scenario;
409
+ fault?: MatrixFault;
410
+ callbacks?: CallbackMode;
411
+ /** Extra arrangement after the scenario's setup, before the intent (e.g. a lost dispatch response). */
412
+ arrange?: (t: Tenant) => Promise<void>;
413
+ }
414
+
415
+ function slug(text: string): string {
416
+ return text.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase();
417
+ }
418
+
419
+ /** The tenant id never survives into a comparison: every cell has its own. */
420
+ export function normalize<T>(value: T, tenantId: string): T {
421
+ return JSON.parse(JSON.stringify(value).split(tenantId).join("<tenant>")) as T;
422
+ }
423
+
424
+ /** Wait until nothing is active and every compensation has settled (or the budget runs out). */
425
+ export async function settle(t: Tenant, timeoutMs = 20_000): Promise<Health> {
426
+ const deadline = Date.now() + timeoutMs;
427
+ for (;;) {
428
+ const health = await t.matrix<Health>("health");
429
+ const unsettled = health.compensations.filter((c) => !(c.exhausted && c.reported));
430
+ if ((health.active.length === 0 && unsettled.length === 0) || Date.now() > deadline) return health;
431
+ await sleep(50);
432
+ }
433
+ }
434
+
435
+ export async function collect(t: Tenant, jobId: string, jobIds: string[], job: JobView, notes: DriveNotes): Promise<CellRun> {
436
+ const health = await settle(t);
437
+ const [log, all, kills, effects, execs, dispatches, end, final] = await Promise.all([
438
+ t.transitions(jobId),
439
+ t.transitions(),
440
+ t.matrix<MatrixKill[]>("kills"),
441
+ t.matrix<MatrixEffect[]>("effects", { jobId }),
442
+ t.matrix<{ step: string; n: number }[]>("execs", { jobId }),
443
+ t.harness<CiDispatch[]>("dispatches"),
444
+ t.matrix<EndState>("endState", { tenantId: t.id }),
445
+ t.job(jobId),
446
+ ]);
447
+ return {
448
+ tenant: t,
449
+ jobId,
450
+ jobIds,
451
+ job: final ?? job,
452
+ notes,
453
+ log,
454
+ all,
455
+ kills,
456
+ effects,
457
+ execs,
458
+ dispatches,
459
+ end: normalize(end, t.id),
460
+ health,
461
+ };
462
+ }
463
+
464
+ export async function runCell(spec: CellSpec): Promise<CellRun> {
465
+ const t = new Tenant(uniqueTenant(slug(spec.scenario.id)));
466
+ await spec.scenario.setup(t);
467
+ await spec.arrange?.(t);
468
+ if (spec.fault) await t.matrix("arm", spec.fault as unknown as Record<string, unknown>);
469
+ const intent = spec.scenario.intent(t);
470
+ const jobIds: string[] = [];
471
+ for (let i = 0; i < (spec.scenario.deliveries ?? 1); i += 1) jobIds.push(await t.submit(intent.kind, intent.input));
472
+ const jobId = jobIds[0]!;
473
+ const { job, notes } = await drive(t, jobId, spec.callbacks ? { callbacks: spec.callbacks } : {});
474
+ return collect(t, jobId, jobIds, job, notes);
475
+ }
476
+
477
+ const baselines = new Map<string, Promise<CellRun>>();
478
+
479
+ /** The scenario's unfaulted run, once per suite. */
480
+ export function baselineOf(scenario: Scenario): Promise<CellRun> {
481
+ let run = baselines.get(scenario.id);
482
+ if (!run) {
483
+ run = runCell({ scenario });
484
+ baselines.set(scenario.id, run);
485
+ }
486
+ return run;
487
+ }
488
+
489
+ // ── Convergence ───────────────────────────────────────────────────────────────
490
+
491
+ /**
492
+ * External effects that must happen at most as often as in the unfaulted run (never twice for one
493
+ * cause). The others are keyed or idempotent in production, so a resumed step repeating them is
494
+ * correct: `forge:prepare-undo` opens the undo candidate under a fixed change id (`undoChangeId`),
495
+ * `forge:forward-integrate` merges current main into preview ("already up to date" the second time),
496
+ * `notice:went-live` is deduplicated per post (`socialPostedKey`), and pointer, assets, report,
497
+ * designation and build writes put the same value again.
498
+ */
499
+ export const ONCE_EFFECTS: ReadonlySet<string> = new Set([
500
+ "forge:merge",
501
+ "forge:rebase",
502
+ "email:conflict",
503
+ "ci:stage-aggregate",
504
+ "ci:www-publish",
505
+ "ci:www-rollback",
506
+ "ci:www-promote",
507
+ "ci:www-candidate-prebuild",
508
+ "forge:merge-to-main",
509
+ ]);
510
+
511
+ const OUTCOME_NOTES = new Set(["step-done", "checkpoint", "lease-expired", "yielded"]);
512
+
513
+ export function stepStarts(log: TransitionView[]): string[] {
514
+ return [...new Set(log.filter((t) => t.note?.startsWith("step-start")).map((t) => t.step))];
515
+ }
516
+
517
+ export function countBy<T>(items: T[], key: (item: T) => string): Record<string, number> {
518
+ const out: Record<string, number> = {};
519
+ for (const item of items) out[key(item)] = (out[key(item)] ?? 0) + 1;
520
+ return out;
521
+ }
522
+
523
+ /** The job's log is one complete chain: created, started, each step start resolved, one terminal state at the end. */
524
+ export function chainViolations(log: TransitionView[]): string[] {
525
+ const v: string[] = [];
526
+ if (log.length < 3) return [`transition log has ${log.length} entries`];
527
+ if (log[0]!.from !== "none" || log[0]!.to !== "queued") v.push(`log starts ${log[0]!.from}→${log[0]!.to}`);
528
+ for (let i = 1; i < log.length; i += 1) {
529
+ if (log[i]!.from !== log[i - 1]!.to) v.push(`transition ${i} (${log[i]!.note}) does not continue the chain`);
530
+ }
531
+ const terminals = log.filter((t) => TERMINAL.has(t.to) && !TERMINAL.has(t.from));
532
+ if (terminals.length !== 1) v.push(`${terminals.length} terminal transitions`);
533
+ for (let i = 0; i < log.length; i += 1) {
534
+ const t = log[i]!;
535
+ if (!t.note?.startsWith("step-start")) continue;
536
+ const next = log[i + 1];
537
+ const resolved =
538
+ next &&
539
+ ((next.step === t.step && next.attempt === t.attempt && (OUTCOME_NOTES.has(next.note ?? "") || next.note?.startsWith("step-failed"))) ||
540
+ next.note === "step-start; previous execution did not finish" ||
541
+ next.to === "awaiting-external" ||
542
+ TERMINAL.has(next.to));
543
+ if (!resolved) v.push(`step-start ${t.step}#${t.attempt} has no recorded outcome`);
544
+ }
545
+ return v;
546
+ }
547
+
548
+ /** Across every job of the tenant: between a step's start and its outcome, no other step starts. */
549
+ export function overlapViolations(all: TransitionView[]): string[] {
550
+ let open: TransitionView | null = null;
551
+ for (const t of all) {
552
+ if (t.note?.startsWith("step-start")) {
553
+ if (open && open.jobId !== t.jobId) return [`step ${t.step} of ${t.jobId} started while ${open.step} of ${open.jobId} was running`];
554
+ open = t;
555
+ } else if (
556
+ open &&
557
+ t.jobId === open.jobId &&
558
+ (OUTCOME_NOTES.has(t.note ?? "") || t.note?.startsWith("step-failed") || t.to === "awaiting-external" || TERMINAL.has(t.to))
559
+ ) {
560
+ open = null;
561
+ }
562
+ }
563
+ return [];
564
+ }
565
+
566
+ function diffKeys(a: Record<string, unknown>, b: Record<string, unknown>, keys: string[]): string[] {
567
+ return keys.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
568
+ }
569
+
570
+ export const LIVE_END_KEYS = ["live", "liveRecords", "truth", "receipts", "promotion", "cloudfront", "assetsLive"] as const;
571
+ /** Every end-state key a cell is compared on. */
572
+ const END_KEYS = [...LIVE_END_KEYS, "legacy", "preview"] as const;
573
+
574
+ export interface ConvergenceOptions {
575
+ /** The fault was armed and must have fired. */
576
+ expectKill: boolean;
577
+ /** End-state keys this cell legitimately changes (with why, in the caller). */
578
+ ignoreEnd?: string[];
579
+ /** Effect counts this cell legitimately adds (a cancelled run's re-dispatch). */
580
+ extraEffects?: Record<string, number>;
581
+ }
582
+
583
+ /**
584
+ * Everything the convergence guarantee says about one cell, as findings named
585
+ * `<assertion>: <detail>` (empty when it holds):
586
+ * verdict the job reaches the unfaulted run's terminal verdict
587
+ * orphan nothing is left non-terminal (or a compensation unsettled) without an alarm — at the
588
+ * kill instant and at the end
589
+ * live==record `live` and the engine's live record agree (or the ledger surfaces unprovenLive /
590
+ * liveDisagrees) at the end; at the kill instant they may differ only while the job's
591
+ * compensation is armed to put `live` back
592
+ * duplicate no once-only external effect (merge, dispatch, email, promote, notice) happens more
593
+ * often than unfaulted
594
+ * compensation a pending compensation settles
595
+ * coalesced a duplicate delivery of one intent is the same job
596
+ * log the job's transition log is one complete chain and finishes the unfaulted steps, in order
597
+ * end-state what the job leaves behind equals the unfaulted run's
598
+ */
599
+ export function convergence(cell: CellRun, base: CellRun, opts: ConvergenceOptions): string[] {
600
+ const v: string[] = [];
601
+ const { job } = cell;
602
+ if (job.state !== base.job.state || job.error?.code !== base.job.error?.code) {
603
+ v.push(`verdict: ${job.state}${job.error ? ` (${job.error.code}: ${job.error.reason})` : ""}, unfaulted ${base.job.state}`);
604
+ }
605
+ if (opts.expectKill && cell.kills.length === 0) v.push("fired: the armed fault never fired");
606
+
607
+ for (const kill of cell.kills) {
608
+ const where = `${kill.step}/${kill.mode}${kill.effect ? `:${kill.effect}` : ""}`;
609
+ if (kill.alarm === null && (kill.activeJobs > 0 || kill.pendingCompensations > 0)) {
610
+ v.push(`orphan: killed at ${where} with ${kill.activeJobs} active job(s) and no alarm to resume them`);
611
+ }
612
+ // `live` may run ahead of the record only while something will reconcile them: the job's armed
613
+ // compensation (a Cloudflare flip before its proof), or — CloudFront — `live` naming what the
614
+ // public host is already proven to serve, which the resumed job records.
615
+ if (kill.live !== kill.record && !kill.armed && !(kill.served !== null && kill.live === kill.served)) {
616
+ v.push(`live==record: killed at ${where} with live=${kill.live} record=${kill.record} and no compensation armed`);
617
+ }
618
+ }
619
+ for (const a of cell.health.active) v.push(`orphan: job ${a.jobId === cell.jobId ? "under test" : a.jobId} still ${a.state}`);
620
+ for (const c of cell.health.compensations.filter((c) => !(c.exhausted && c.reported))) {
621
+ v.push(`compensation: ${c.jobId === cell.jobId ? "the job's" : c.jobId} compensation never settled${cell.health.alarm === null ? " (and no alarm)" : ""}`);
622
+ }
623
+
624
+ if (new Set(cell.jobIds).size !== 1) v.push(`coalesced: ${cell.jobIds.length} deliveries made ${new Set(cell.jobIds).size} jobs`);
625
+
626
+ const end = cell.end;
627
+ const input = job.input as { provider?: string | null };
628
+ if (job.lane === "live" && "provider" in input && job.state === "succeeded") {
629
+ const provider = input.provider ?? "worker-r2-pointer";
630
+ const record = end.liveRecords.find((r) => r.provider === provider)?.sha ?? null;
631
+ const receiptSha = end.receipts.find((r) => r.receiptId === end.truth?.liveReceiptId)?.sha ?? null;
632
+ const surfaced = end.truth?.unprovenLive != null || end.truth?.liveDisagrees != null;
633
+ if (!surfaced && (end.live.versionId !== record || end.live.versionId !== receiptSha)) {
634
+ v.push(`live==record: live=${end.live.versionId} record=${record} ledger=${receiptSha}, nothing surfaced`);
635
+ }
636
+ if (provider === "s3-overwrite" && !surfaced && end.cloudfront?.sha !== end.live.versionId) {
637
+ v.push(`live==record: CloudFront serves ${end.cloudfront?.sha ?? "nothing"} while live names ${end.live.versionId}`);
638
+ }
639
+ }
640
+
641
+ const cellOnce = countBy(cell.effects.filter((e) => ONCE_EFFECTS.has(e.effect)), (e) => e.effect);
642
+ const baseOnce = countBy(base.effects.filter((e) => ONCE_EFFECTS.has(e.effect)), (e) => e.effect);
643
+ for (const [effect, n] of Object.entries(cellOnce)) {
644
+ const allowed = (baseOnce[effect] ?? 0) + (opts.extraEffects?.[effect] ?? 0);
645
+ if (n > allowed) {
646
+ const steps = [...new Set(cell.effects.filter((e) => e.effect === effect).map((e) => e.step))].join(",");
647
+ v.push(`duplicate: ${effect} ${n}× (unfaulted ${baseOnce[effect] ?? 0}×) at ${steps}`);
648
+ }
649
+ }
650
+
651
+ for (const finding of chainViolations(cell.log)) v.push(`log: ${finding}`);
652
+ const done = cell.log.filter((t) => t.note === "step-done").map((t) => t.step);
653
+ const baseDone = base.log.filter((t) => t.note === "step-done").map((t) => t.step);
654
+ if (JSON.stringify(done) !== JSON.stringify(baseDone)) v.push(`log: finished [${done.join(",")}], unfaulted [${baseDone.join(",")}]`);
655
+
656
+ const keys = END_KEYS.filter((k) => !opts.ignoreEnd?.includes(k));
657
+ for (const key of diffKeys(end as unknown as Record<string, unknown>, base.end as unknown as Record<string, unknown>, keys)) {
658
+ v.push(`end-state: ${key} = ${JSON.stringify((end as unknown as Record<string, unknown>)[key])}, unfaulted ${JSON.stringify((base.end as unknown as Record<string, unknown>)[key])}`);
659
+ }
660
+ return v;
661
+ }
@@ -0,0 +1,13 @@
1
+ // The systematic fault matrix (test/pipeline-faults/), inside workerd — see
2
+ // test/pipeline/pool-config.cjs. Every cell runs against its own tenant's object, so cells run
3
+ // concurrently; the suite's wall-time budget is two minutes (CI job `pipeline-faults`).
4
+ process.env.VITE_CJS_IGNORE_WARNING = "1";
5
+ const { pipelinePoolConfig } = require("./test/pipeline/pool-config.cjs");
6
+
7
+ module.exports = pipelinePoolConfig({
8
+ root: __dirname,
9
+ name: "pipeline-faults",
10
+ include: ["test/pipeline-faults/**/*.test.ts"],
11
+ maxConcurrency: 24,
12
+ testTimeout: 90_000,
13
+ });