@tokenoftrust/storefront-runner 2.4.1 → 2.4.3

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,278 @@
1
+ /**
2
+ * Fake {@link PreviewJobServices} for the TenantPipeline fault harness: a forge, a version store
3
+ * and a release read model held in the Durable Object's own SQLite (`fake_*` tables), so every
4
+ * effect survives the evictions the tests inject and can be counted afterwards.
5
+ *
6
+ * Faults are configured per object (one object per tenant) through `fake_faults` rows:
7
+ * - `evict-after-forge-merge` the forge merges, then the object is evicted before the merge
8
+ * step records anything (the placeholder-sha hole)
9
+ * - `evict-before-build` evicted when the build step starts (after the merge was recorded)
10
+ * - `evict-at-file:<n>` evicted while the build processes file n
11
+ * - `slow-read` every build read fails with a typed transient timeout
12
+ * - `pointer-moves-to:<sha>` while the build runs, the preview webhook moves the shared preview
13
+ * to <sha>, a descendant of the commit being built
14
+ * - `evict-after-email:<id>` the follow-up's rebase of candidate <id> sends its author a conflict
15
+ * email, then the object is evicted
16
+ * - `reaccept-times-out` after the forge merged, the re-accept of a resumed merge answers a
17
+ * transient timeout instead of a refusal
18
+ * - `slow-build` every file of a build takes a few milliseconds (a build that is
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`).
22
+ * A fault fires `times` times (0 = every time); its firing is persisted before it acts.
23
+ */
24
+ import type { DurableObjectState } from "cloudflare:workers";
25
+ import { ForgeToolError, ForgeTransportError } from "../../src/lib/forge/errors";
26
+ import type {
27
+ MembershipEntry,
28
+ MergeCandidate,
29
+ PreviewJobServices,
30
+ ReadModelTip,
31
+ } from "../../src/lib/pipeline/preview-jobs";
32
+
33
+ /** Files a fake build materializes. */
34
+ export const FAKE_FILE_COUNT = 250;
35
+
36
+ /** How long one file of a `slow-build` takes: the whole build, about a second and a half. */
37
+ export const SLOW_FILE_MS = 6;
38
+
39
+ export interface FakeFault {
40
+ point: string;
41
+ times: number;
42
+ }
43
+
44
+ export interface FakeState {
45
+ merges: { changeId: string; sha: string; message: string }[];
46
+ fileRuns: { sha: string; file: number }[];
47
+ advances: { sha: string; membership: MembershipEntry[]; jobId: string; kind: string }[];
48
+ readModel: { sha: string; membership: MembershipEntry[]; green: boolean } | null;
49
+ previewPointer: string | null;
50
+ staged: string[];
51
+ refreshes: string[];
52
+ emails: string[];
53
+ /** `<sha>:<evidence|refused>`, in report order. */
54
+ reports: string[];
55
+ refused: string[];
56
+ }
57
+
58
+ export function fakeTables(state: DurableObjectState): void {
59
+ const { sql } = state.storage;
60
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_open (change_id TEXT PRIMARY KEY, head_sha TEXT NOT NULL)`);
61
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_merges (seq INTEGER PRIMARY KEY AUTOINCREMENT, change_id TEXT, sha TEXT, message TEXT)`);
62
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_file_runs (seq INTEGER PRIMARY KEY AUTOINCREMENT, sha TEXT, file INTEGER)`);
63
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_advances (seq INTEGER PRIMARY KEY AUTOINCREMENT, sha TEXT, membership TEXT, job_id TEXT, kind TEXT)`);
64
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_read_model (k INTEGER PRIMARY KEY CHECK (k = 1), sha TEXT, membership TEXT, green INTEGER)`);
65
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_faults (point TEXT PRIMARY KEY, times INTEGER NOT NULL, fired INTEGER NOT NULL DEFAULT 0)`);
66
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_pointer (k INTEGER PRIMARY KEY CHECK (k = 1), sha TEXT)`);
67
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_ancestry (ancestor TEXT, descendant TEXT, PRIMARY KEY (ancestor, descendant))`);
68
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_events (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, subject TEXT)`);
69
+ sql.exec(`CREATE TABLE IF NOT EXISTS fake_candidates (change_id TEXT PRIMARY KEY, pr INTEGER, conflicts INTEGER)`);
70
+ }
71
+
72
+ export function configureFake(
73
+ state: DurableObjectState,
74
+ config: {
75
+ open?: { changeId: string; headSha: string }[];
76
+ faults?: FakeFault[];
77
+ /** Other open candidates a base advance rebuilds; `conflicts` ones email their author. */
78
+ rebaseCandidates?: { changeId: string; pr: number; conflicts?: boolean }[];
79
+ /** `[ancestor, descendant]` commit pairs the fake forge answers `isAncestor` true for. */
80
+ ancestry?: [string, string][];
81
+ },
82
+ ): void {
83
+ fakeTables(state);
84
+ const { sql } = state.storage;
85
+ for (const c of config.open ?? []) {
86
+ sql.exec(`INSERT OR REPLACE INTO fake_open (change_id, head_sha) VALUES (?, ?)`, c.changeId, c.headSha);
87
+ }
88
+ for (const f of config.faults ?? []) {
89
+ sql.exec(`INSERT OR REPLACE INTO fake_faults (point, times, fired) VALUES (?, ?, 0)`, f.point, f.times);
90
+ }
91
+ for (const [ancestor, descendant] of config.ancestry ?? []) {
92
+ sql.exec(`INSERT OR IGNORE INTO fake_ancestry (ancestor, descendant) VALUES (?, ?)`, ancestor, descendant);
93
+ }
94
+ for (const c of config.rebaseCandidates ?? []) {
95
+ sql.exec(`INSERT OR REPLACE INTO fake_candidates (change_id, pr, conflicts) VALUES (?, ?, ?)`, c.changeId, c.pr, c.conflicts ? 1 : 0);
96
+ }
97
+ }
98
+
99
+ export function readFakeState(state: DurableObjectState): FakeState {
100
+ fakeTables(state);
101
+ const { sql } = state.storage;
102
+ const rm = sql.exec<{ sha: string; membership: string; green: number }>(`SELECT * FROM fake_read_model`).toArray()[0];
103
+ const pointer = sql.exec<{ sha: string }>(`SELECT sha FROM fake_pointer`).toArray()[0];
104
+ const events = (kind: string) =>
105
+ sql.exec<{ subject: string }>(`SELECT subject FROM fake_events WHERE kind = ? ORDER BY seq`, kind).toArray().map((r) => r.subject);
106
+ return {
107
+ merges: sql
108
+ .exec<{ change_id: string; sha: string; message: string }>(`SELECT * FROM fake_merges ORDER BY seq`)
109
+ .toArray()
110
+ .map((r) => ({ changeId: r.change_id, sha: r.sha, message: r.message })),
111
+ fileRuns: sql.exec<{ sha: string; file: number }>(`SELECT sha, file FROM fake_file_runs ORDER BY seq`).toArray(),
112
+ advances: sql
113
+ .exec<{ sha: string; membership: string; job_id: string; kind: string }>(`SELECT * FROM fake_advances ORDER BY seq`)
114
+ .toArray()
115
+ .map((r) => ({ sha: r.sha, membership: JSON.parse(r.membership), jobId: r.job_id, kind: r.kind })),
116
+ readModel: rm ? { sha: rm.sha, membership: JSON.parse(rm.membership), green: rm.green === 1 } : null,
117
+ previewPointer: pointer?.sha ?? null,
118
+ staged: events("stage"),
119
+ refreshes: events("refresh"),
120
+ emails: events("email"),
121
+ reports: events("report"),
122
+ refused: events("refused"),
123
+ };
124
+ }
125
+
126
+ export function fakePreviewServices(state: DurableObjectState): PreviewJobServices {
127
+ fakeTables(state);
128
+ const { sql } = state.storage;
129
+
130
+ /** True when `point` should fire now; records the firing durably first. */
131
+ async function fires(point: string): Promise<boolean> {
132
+ const row = sql.exec<{ times: number; fired: number }>(`SELECT times, fired FROM fake_faults WHERE point = ?`, point).toArray()[0];
133
+ if (!row) return false;
134
+ if (row.times !== 0 && row.fired >= row.times) return false;
135
+ sql.exec(`UPDATE fake_faults SET fired = fired + 1 WHERE point = ?`, point);
136
+ await state.storage.sync();
137
+ return true;
138
+ }
139
+
140
+ function evict(point: string): never {
141
+ state.abort(`synthetic eviction ${point}`);
142
+ throw new Error("abort() returned");
143
+ }
144
+
145
+ const setPointer = (sha: string) =>
146
+ sql.exec(`INSERT INTO fake_pointer (k, sha) VALUES (1, ?) ON CONFLICT(k) DO UPDATE SET sha = excluded.sha`, sha);
147
+
148
+ return {
149
+ async integrateCandidate(_repo, changeId, opts) {
150
+ const open = sql.exec<{ head_sha: string }>(`SELECT head_sha FROM fake_open WHERE change_id = ?`, changeId).toArray()[0];
151
+ if (!open) {
152
+ if (await fires("reaccept-times-out")) {
153
+ throw new ForgeTransportError("timed out reading the body", { transient: true, errorKind: "timeout" });
154
+ }
155
+ throw new ForgeToolError(`No open candidate "${changeId}".`, { data: { status: "not_found" } });
156
+ }
157
+ const n = sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM fake_merges`).one().n;
158
+ const sha = `sha-${n + 1}-${changeId}`;
159
+ sql.exec(`DELETE FROM fake_open WHERE change_id = ?`, changeId);
160
+ sql.exec(`INSERT INTO fake_merges (change_id, sha, message) VALUES (?, ?, ?)`, changeId, sha, opts.message);
161
+ await state.storage.sync();
162
+ if (await fires("evict-after-forge-merge")) evict("after forge merge");
163
+ return { sha, integratedInto: "preview" };
164
+ },
165
+
166
+ async candidatePaths() {
167
+ return ["content/home.json"];
168
+ },
169
+
170
+ async findMergedPreviewCommit(_repo, paths, { tag }) {
171
+ if (paths.length === 0) return null;
172
+ const row = sql
173
+ .exec<{ sha: string; message: string }>(`SELECT sha, message FROM fake_merges ORDER BY seq DESC`)
174
+ .toArray()
175
+ .find((r) => r.message.split("\n", 1)[0]!.includes(tag));
176
+ return row?.sha ?? null;
177
+ },
178
+
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
+ async materialize({ sha, resume }) {
185
+ if (await fires("evict-before-build")) evict("before build");
186
+ const moveTo = sql
187
+ .exec<{ point: string }>(`SELECT point FROM fake_faults WHERE point LIKE 'pointer-moves-to:%'`)
188
+ .toArray()[0]?.point;
189
+ if (moveTo && (await fires(moveTo))) {
190
+ const descendant = moveTo.slice("pointer-moves-to:".length);
191
+ sql.exec(`INSERT OR IGNORE INTO fake_ancestry (ancestor, descendant) VALUES (?, ?)`, sha, descendant);
192
+ setPointer(descendant);
193
+ }
194
+ if (await fires("slow-read")) {
195
+ return {
196
+ ok: false,
197
+ errors: ["fetch content/catalog: timed out"],
198
+ cause: new ForgeTransportError("timed out reading the body", { transient: true, errorKind: "timeout" }),
199
+ };
200
+ }
201
+ const slow = await fires("slow-build");
202
+ for (let file = 0; file < FAKE_FILE_COUNT; file += 1) {
203
+ const path = `content/file-${file}.json`;
204
+ if (resume.done(path)) continue;
205
+ if (slow) await new Promise((resolve) => setTimeout(resolve, SLOW_FILE_MS));
206
+ if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
207
+ sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
208
+ await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
209
+ }
210
+ return { ok: true, versionId: sha, digest: `digest-${sha}` };
211
+ },
212
+
213
+ async evidence({ sha }) {
214
+ return { promotable: true, report: { sha, promotable: true, checks: [] } };
215
+ },
216
+
217
+ async reportOutcome({ sha, outcome }) {
218
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('report', ?)`, `${sha}:${outcome.kind}`);
219
+ return { reported: true };
220
+ },
221
+
222
+ async recordPreviewRefused({ sha }) {
223
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('refused', ?)`, sha);
224
+ },
225
+
226
+ async previewPointer() {
227
+ return sql.exec<{ sha: string }>(`SELECT sha FROM fake_pointer`).toArray()[0]?.sha ?? null;
228
+ },
229
+
230
+ async isAncestor(_repo, ancestor, descendant) {
231
+ if (ancestor === descendant) return true;
232
+ return sql.exec(`SELECT 1 FROM fake_ancestry WHERE ancestor = ? AND descendant = ?`, ancestor, descendant).toArray().length > 0;
233
+ },
234
+
235
+ async advancePreview({ sha, membership, jobId, kind, releasable }) {
236
+ setPointer(sha);
237
+ if (!releasable) return;
238
+ sql.exec(
239
+ `INSERT INTO fake_advances (sha, membership, job_id, kind) VALUES (?, ?, ?, ?)`,
240
+ sha,
241
+ JSON.stringify(membership),
242
+ jobId,
243
+ kind,
244
+ );
245
+ sql.exec(
246
+ `INSERT INTO fake_read_model (k, sha, membership, green) VALUES (1, ?, ?, 1)
247
+ ON CONFLICT(k) DO UPDATE SET sha = excluded.sha, membership = excluded.membership, green = 1`,
248
+ sha,
249
+ JSON.stringify(membership),
250
+ );
251
+ },
252
+
253
+ async stageAggregate({ sha }) {
254
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('stage', ?)`, sha);
255
+ },
256
+
257
+ async listRebaseCandidates({ acceptedChangeId }) {
258
+ return sql
259
+ .exec<{ change_id: string; pr: number }>(`SELECT change_id, pr FROM fake_candidates WHERE change_id != ? ORDER BY change_id`, acceptedChangeId)
260
+ .toArray()
261
+ .map((r) => ({ changeId: r.change_id, prNumber: r.pr }));
262
+ },
263
+
264
+ async rebaseCandidate({ candidate }) {
265
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('refresh', ?)`, candidate.changeId);
266
+ const conflicts = sql.exec<{ conflicts: number }>(`SELECT conflicts FROM fake_candidates WHERE change_id = ?`, candidate.changeId).toArray()[0];
267
+ if (!conflicts?.conflicts) return { outcome: "refreshed" as const };
268
+ sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('email', ?)`, candidate.changeId);
269
+ await state.storage.sync();
270
+ if (await fires(`evict-after-email:${candidate.changeId}`)) evict(`after emailing ${candidate.changeId}`);
271
+ return { outcome: "conflict" as const, notified: true };
272
+ },
273
+
274
+ async prepareUndo(): Promise<MergeCandidate> {
275
+ throw new Error("prepareUndo is not exercised by the fault harness");
276
+ },
277
+ };
278
+ }
@@ -21,7 +21,7 @@ interface HarnessEnv {
21
21
  }
22
22
 
23
23
  type TenantPipelineStub = {
24
- [K in "submit" | "job" | "jobs" | "transitions" | "facts" | "harness" | "probeStaleWrites"]: TenantPipeline[K];
24
+ [K in "submit" | "job" | "jobs" | "transitions" | "facts" | "harness" | "probeStaleWrites" | "configureFake" | "fakeState" | "spans"]: TenantPipeline[K];
25
25
  };
26
26
 
27
27
  export const harnessEnv = env as unknown as HarnessEnv;
@@ -45,6 +45,23 @@ export interface Submitted {
45
45
  state: string;
46
46
  }
47
47
 
48
+ /** Submit an intent of any kind through a Worker's intake (its own isolate), timing the answer. */
49
+ export async function submitIntentVia(
50
+ tenant: string,
51
+ intent: { kind: string; input: unknown },
52
+ via: Fetcher = harnessEnv.PIPELINE_HOST,
53
+ ): Promise<Submitted> {
54
+ const started = performance.now();
55
+ const res = await via.fetch(`https://pipeline.test/intents/${encodeURIComponent(tenant)}`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ ...intent, actor: { type: "test", id: "harness" }, admissionDecisionId: null }),
59
+ });
60
+ const ms = performance.now() - started;
61
+ const body = (await res.json()) as { jobId: string; state: string };
62
+ return { status: res.status, jobId: body.jobId, ms, intake: res.headers.get("x-intake"), state: body.state };
63
+ }
64
+
48
65
  export async function submit(
49
66
  tenant: string,
50
67
  faults: Fault[] = [],
@@ -68,7 +85,7 @@ export async function submit(
68
85
  return { status: res.status, jobId: body.jobId, ms, intake: res.headers.get("x-intake"), state: body.state };
69
86
  }
70
87
 
71
- const TERMINAL = new Set<JobState>(["succeeded", "failed", "stuck"]);
88
+ const TERMINAL = new Set<JobState>(["succeeded", "needs-developer", "stuck"]);
72
89
 
73
90
  /**
74
91
  * Polls until every job is terminal. On every sample asserts the tenant never has more than one
@@ -100,14 +117,19 @@ export async function waitTerminal(tenant: string, jobIds: string[], timeoutMs =
100
117
  }
101
118
  }
102
119
 
103
- const OUTCOME_NOTES = ["step-done", "checkpoint", "lease-expired"];
120
+ const OUTCOME_NOTES = ["step-done", "checkpoint", "lease-expired", "yielded"];
104
121
 
105
122
  /**
106
123
  * The job's transition log is a complete chain: created queued, started once, every step start
107
124
  * has exactly one recorded outcome (or is superseded by a resume after an eviction), each step
108
125
  * finished exactly once and in order, and it ends at `terminal` with nothing after it.
109
126
  */
110
- export function assertCompleteLog(log: TransitionView[], terminal: JobState, stepNames = ["s1", "s2", "s3", "s4", "s5"]): void {
127
+ export function assertCompleteLog(
128
+ log: TransitionView[],
129
+ terminal: JobState,
130
+ stepNames = ["s1", "s2", "s3", "s4", "s5"],
131
+ opts: { checkpointsOf?: { step: string; count: number } | null } = {},
132
+ ): void {
111
133
  expect(log.length).toBeGreaterThan(2);
112
134
  expect([log[0]!.from, log[0]!.to]).toEqual(["none", "queued"]);
113
135
  expect([log[1]!.from, log[1]!.to]).toEqual(["queued", "running"]);
@@ -136,7 +158,8 @@ export function assertCompleteLog(log: TransitionView[], terminal: JobState, ste
136
158
  if (terminal === "succeeded") {
137
159
  const done = log.filter((t) => t.note === "step-done").map((t) => t.step);
138
160
  expect(done).toEqual(stepNames);
139
- expect(log.filter((t) => t.note === "checkpoint" && t.step === "s3")).toHaveLength(S3_CHUNKS - 1);
161
+ const checkpoints = opts.checkpointsOf === undefined ? { step: "s3", count: S3_CHUNKS - 1 } : opts.checkpointsOf;
162
+ if (checkpoints) expect(log.filter((t) => t.note === "checkpoint" && t.step === checkpoints.step)).toHaveLength(checkpoints.count);
140
163
  }
141
164
  }
142
165
 
@@ -2,17 +2,49 @@
2
2
  * The Worker that hosts the TenantPipeline Durable Object for the harness, in its own isolate
3
3
  * (as in production, where callers never share the object's isolate — and where an eviction
4
4
  * restarts the object without touching its callers). It runs the production engine with the
5
- * synthetic job kinds registered and answers intents through `submitIntent`.
5
+ * synthetic job kinds AND the production preview kinds (integrate / rebuild / revert / sync-preview) over fake
6
+ * services (`./fake-preview-services.ts`), and answers intents through `submitIntent`.
6
7
  */
7
8
  import type { DurableObjectState } from "cloudflare:workers";
8
9
  import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
9
- import { submitIntent, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
10
+ import { submitIntent, tenantPipeline, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
11
+ import { submitPreviewSync } from "../../src/lib/pipeline/preview-sync-intake";
10
12
  import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
11
- import { abandonedContexts, syntheticJobKinds } from "./synthetic-job";
13
+ import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
14
+ import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
15
+ import { configureFake, fakePreviewServices, readFakeState, type FakeFault, type FakeState } from "./fake-preview-services";
16
+
17
+ /** The preview kinds' timing in the harness: the production shape, scaled to milliseconds. */
18
+ export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
19
+ leaseMs: 1_000,
20
+ deadlineMs: 30_000,
21
+ maxAttemptsPerStep: 4,
22
+ retryBaseMs: 10,
23
+ defaultExpectedMs: 2_000,
24
+ };
12
25
 
13
26
  export class TenantPipeline extends DeployedTenantPipeline {
14
27
  protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
15
- return syntheticJobKinds(ctx);
28
+ return registerPreviewJobKinds(syntheticJobKinds(ctx), fakePreviewServices(ctx), HARNESS_PREVIEW_TIMING);
29
+ }
30
+
31
+ /** Harness-only: open candidates and arm faults for the fake preview services. */
32
+ async configureFake(config: {
33
+ open?: { changeId: string; headSha: string }[];
34
+ faults?: FakeFault[];
35
+ ancestry?: [string, string][];
36
+ }): Promise<void> {
37
+ configureFake(this.ctx, config);
38
+ }
39
+
40
+ /** Harness-only: execution spans of the cooperative-hang kind. */
41
+ async spans(jobId: string): Promise<{ started: number; ended: number | null }[]> {
42
+ return syntheticSpans(this.ctx, jobId);
43
+ }
44
+
45
+ /** Harness-only: what the fake forge, build and read model recorded. */
46
+ async fakeState(): Promise<FakeState> {
47
+ return readFakeState(this.ctx);
16
48
  }
17
49
 
18
50
  /** Harness-only reads of the synthetic step log. */
@@ -64,8 +96,28 @@ interface Env {
64
96
  TENANT_PIPELINE: TenantPipelineNamespace;
65
97
  }
66
98
 
99
+ /**
100
+ * `/sync/<tenant>` answers a forwarded push the way `/api/preview/reconcile` does: submit the
101
+ * `sync-preview` intent and answer 202. `/sync-hold/<tenant>` submits, then holds the response open
102
+ * (a forward the caller kills mid-flight): the job must not depend on the request.
103
+ */
104
+ async function syncRoute(request: Request, env: Env, tenant: string, hold: boolean): Promise<Response> {
105
+ const body = (await request.json()) as { repo: string; sha: string };
106
+ const result = await submitPreviewSync(
107
+ tenantPipeline(env.TENANT_PIPELINE, tenant),
108
+ tenant,
109
+ { repo: body.repo, sha: body.sha, source: "push" },
110
+ { actor: { type: "service", id: "harness-forward" }, admissionDecisionId: null },
111
+ );
112
+ if (!result.ok) return Response.json({ error: result.code }, { status: 400 });
113
+ if (hold) await new Promise((resolve) => setTimeout(resolve, 10_000));
114
+ return Response.json({ jobId: result.jobId, state: result.state, duplicate: result.duplicate }, { status: 202 });
115
+ }
116
+
67
117
  export default {
68
118
  async fetch(request: Request, env: Env): Promise<Response> {
119
+ const sync = /^\/(sync|sync-hold)\/([^/]+)$/.exec(new URL(request.url).pathname);
120
+ if (request.method === "POST" && sync) return syncRoute(request, env, decodeURIComponent(sync[2]!), sync[1] === "sync-hold");
69
121
  const match = /^\/intents\/([^/]+)$/.exec(new URL(request.url).pathname);
70
122
  if (request.method !== "POST" || !match) return new Response("not found", { status: 404 });
71
123
  const response = await submitIntent(env.TENANT_PIPELINE, decodeURIComponent(match[1]!), (await request.json()) as PipelineIntent);
@@ -7,12 +7,14 @@
7
7
  * execution is logged to `synthetic_runs` (to prove resume-not-restart) and every effect to
8
8
  * `synthetic_effects` with INSERT OR IGNORE (the step is idempotent, as the engine requires).
9
9
  *
10
- * The intent carries a fault plan: throw / hang past the lease / evict the object, at a step
10
+ * The intent carries a fault plan: throw a transient fault / hang past the lease / evict the
11
+ * object / refuse (a developer-class refusal) / crash (a platform-class error), at a step
11
12
  * boundary (`before` the step's work, `after` it but before the engine records it) or `mid` s3
12
13
  * (after its first chunk's checkpoint). A fault fires once unless `persistent`; its firing is
13
14
  * persisted before it acts so it survives the eviction it causes.
14
15
  */
15
16
  import type { DurableObjectState } from "cloudflare:workers";
17
+ import { PipelineRefusal } from "../../src/lib/pipeline/classify";
16
18
  import {
17
19
  InvalidJobInput,
18
20
  JobKindRegistry,
@@ -23,7 +25,16 @@ import {
23
25
  type StepOutcome,
24
26
  } from "../../src/lib/pipeline/job-runner";
25
27
 
26
- export type FaultMode = "throw" | "hang" | "evict";
28
+ export type FaultMode = "throw" | "hang" | "evict" | "refuse" | "crash";
29
+
30
+ /**
31
+ * The transient fault a `throw` raises: a typed connection reset, which `classify()` names
32
+ * `transient` — so the engine retries it, exactly as it retries a real network blip.
33
+ */
34
+ export class SyntheticTransientFault extends Error {
35
+ override name = "SyntheticTransientFault";
36
+ readonly code = "ECONNRESET";
37
+ }
27
38
  export interface Fault {
28
39
  at: "before" | "after" | "mid";
29
40
  step: number;
@@ -49,7 +60,7 @@ function parseInput(raw: unknown): SyntheticInput {
49
60
  if (!Array.isArray(faults)) throw new InvalidJobInput("faults must be an array");
50
61
  for (const f of faults as Fault[]) {
51
62
  if (!["before", "after", "mid"].includes(f.at)) throw new InvalidJobInput("fault.at is invalid");
52
- if (!["throw", "hang", "evict"].includes(f.mode)) throw new InvalidJobInput("fault.mode is invalid");
63
+ if (!["throw", "hang", "evict", "refuse", "crash"].includes(f.mode)) throw new InvalidJobInput("fault.mode is invalid");
53
64
  if (!Number.isInteger(f.step) || f.step < 1 || f.step > 5) throw new InvalidJobInput("fault.step is invalid");
54
65
  }
55
66
  return { faults: faults as Fault[] };
@@ -81,7 +92,9 @@ function syntheticKind(state: DurableObjectState, kind: string, deadlineMs: numb
81
92
  index,
82
93
  );
83
94
  await state.storage.sync();
84
- if (fault.mode === "throw") throw new Error(`synthetic fault ${at} s${step}`);
95
+ if (fault.mode === "throw") throw new SyntheticTransientFault(`synthetic fault ${at} s${step}`);
96
+ if (fault.mode === "refuse") throw new PipelineRefusal("build_refused", [`synthetic refusal ${at} s${step}`]);
97
+ if (fault.mode === "crash") throw new Error(`synthetic crash ${at} s${step}`);
85
98
  if (fault.mode === "evict") {
86
99
  state.abort(`synthetic eviction ${at} s${step}`);
87
100
  throw new Error("abort() returned");
@@ -148,9 +161,111 @@ function syntheticKind(state: DurableObjectState, kind: string, deadlineMs: numb
148
161
  };
149
162
  }
150
163
 
164
+ // ── Engine-behaviour kinds ──────────────────────────────────────────────────
165
+
166
+ /** s1 then an optional s2 that fails as the intent says (`crash` | `refuse` | `hang`). */
167
+ export const SYNTHETIC_OPTIONAL_TAIL_KIND = "synthetic-optional-tail";
168
+ /** One long step that works in chunks, checkpointing each, for longer than one execution may run. */
169
+ export const SYNTHETIC_LONG_STEP_KIND = "synthetic-long-step";
170
+ /** A step that hangs past its lease but honours its abort signal, then retries clean. */
171
+ export const SYNTHETIC_COOPERATIVE_HANG_KIND = "synthetic-cooperative-hang";
172
+ export const LONG_STEP_CHUNKS = 12;
173
+ export const LONG_STEP_CHUNK_MS = 100;
174
+ export const LONG_STEP_MAX_EXECUTION_MS = 450;
175
+
176
+ function engineKinds(state: DurableObjectState): JobKindDefinition<{ mode?: string }>[] {
177
+ const { sql } = state.storage;
178
+ sql.exec(`CREATE TABLE IF NOT EXISTS synthetic_spans (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, started INTEGER, ended INTEGER)`);
179
+ const base = { leaseMs: SYNTHETIC_LEASE_MS, deadlineMs: SYNTHETIC_DEADLINE_MS, retryBaseMs: 10, defaultExpectedMs: 1_000 };
180
+ const parse = (raw: unknown) => (raw && typeof raw === "object" ? (raw as { mode?: string }) : {});
181
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
182
+ return [
183
+ {
184
+ kind: SYNTHETIC_OPTIONAL_TAIL_KIND,
185
+ parseInput: parse,
186
+ steps: [
187
+ { name: "s1", run: async () => ({ kind: "done" }) },
188
+ {
189
+ name: "s2",
190
+ optional: true,
191
+ run: async (ctx) => {
192
+ if (ctx.input.mode === "refuse") throw new PipelineRefusal("build_refused");
193
+ if (ctx.input.mode === "hang") await sleep(SYNTHETIC_LEASE_MS * 4);
194
+ throw new Error("optional tail crashed");
195
+ },
196
+ },
197
+ ],
198
+ ...base,
199
+ maxAttemptsPerStep: 2,
200
+ },
201
+ {
202
+ kind: SYNTHETIC_LONG_STEP_KIND,
203
+ parseInput: parse,
204
+ steps: [
205
+ {
206
+ name: "long",
207
+ run: async (ctx) => {
208
+ let next = (ctx.checkpoint as { next?: number } | null)?.next ?? 0;
209
+ while (next < LONG_STEP_CHUNKS) {
210
+ if (ctx.signal.aborted) throw new Error("aborted");
211
+ await sleep(LONG_STEP_CHUNK_MS);
212
+ next += 1;
213
+ ctx.saveCheckpoint({ next });
214
+ await ctx.renewLease();
215
+ }
216
+ return { kind: "done" };
217
+ },
218
+ },
219
+ ],
220
+ ...base,
221
+ // One attempt only: reaching the execution cap after checkpointing must not spend it.
222
+ maxAttemptsPerStep: 1,
223
+ maxExecutionMs: LONG_STEP_MAX_EXECUTION_MS,
224
+ },
225
+ {
226
+ kind: SYNTHETIC_COOPERATIVE_HANG_KIND,
227
+ parseInput: parse,
228
+ steps: [
229
+ {
230
+ name: "hang",
231
+ run: async (ctx) => {
232
+ const started = Date.now();
233
+ const seq = sql
234
+ .exec<{ seq: number }>(`INSERT INTO synthetic_spans (job_id, started) VALUES (?, ?) RETURNING seq`, ctx.jobId, started)
235
+ .one().seq;
236
+ const first = sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM synthetic_spans WHERE job_id = ?`, ctx.jobId).one().n === 1;
237
+ try {
238
+ if (first) {
239
+ // Wait for the abort, then take a while to wind down (within one further lease).
240
+ await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve(), { once: true }));
241
+ await sleep(SYNTHETIC_LEASE_MS / 2);
242
+ throw new Error("stopped after abort");
243
+ }
244
+ return { kind: "done" };
245
+ } finally {
246
+ sql.exec(`UPDATE synthetic_spans SET ended = ? WHERE seq = ?`, Date.now(), seq);
247
+ }
248
+ },
249
+ },
250
+ ],
251
+ ...base,
252
+ maxAttemptsPerStep: 5,
253
+ },
254
+ ];
255
+ }
256
+
151
257
  export function syntheticJobKinds(state: DurableObjectState): JobKindRegistry {
152
258
  harnessTables(state);
153
- return new JobKindRegistry()
259
+ const registry = new JobKindRegistry()
154
260
  .register(syntheticKind(state, SYNTHETIC_KIND, SYNTHETIC_DEADLINE_MS))
155
261
  .register(syntheticKind(state, SYNTHETIC_SHORT_DEADLINE_KIND, SHORT_DEADLINE_MS));
262
+ for (const kind of engineKinds(state)) registry.register(kind as JobKindDefinition<unknown> as never);
263
+ return registry;
264
+ }
265
+
266
+ /** Execution spans of the cooperative-hang kind, for overlap checks. */
267
+ export function syntheticSpans(state: DurableObjectState, jobId: string): { started: number; ended: number | null }[] {
268
+ return state.storage.sql
269
+ .exec<{ started: number; ended: number | null }>(`SELECT started, ended FROM synthetic_spans WHERE job_id = ? ORDER BY seq`, jobId)
270
+ .toArray();
156
271
  }
@@ -28,6 +28,8 @@ const hostModule = esbuild.buildSync({
28
28
  const pipelineBinding = { TENANT_PIPELINE: { className: "TenantPipeline", scriptName: HOST } };
29
29
 
30
30
  module.exports = defineWorkersConfig({
31
+ // The source tree's `@/` import alias (tsconfig `paths`), for modules the tests import directly.
32
+ resolve: { alias: { "@": path.join(__dirname, "src") } },
31
33
  test: {
32
34
  name: "pipeline",
33
35
  include: ["test/pipeline/**/*.test.ts"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",