@tokenoftrust/storefront-runner 2.4.2 → 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.
@@ -15,6 +15,10 @@
15
15
  * email, then the object is evicted
16
16
  * - `reaccept-times-out` after the forge merged, the re-accept of a resumed merge answers a
17
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`).
18
22
  * A fault fires `times` times (0 = every time); its firing is persisted before it acts.
19
23
  */
20
24
  import type { DurableObjectState } from "cloudflare:workers";
@@ -29,6 +33,9 @@ import type {
29
33
  /** Files a fake build materializes. */
30
34
  export const FAKE_FILE_COUNT = 250;
31
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
+
32
39
  export interface FakeFault {
33
40
  point: string;
34
41
  times: number;
@@ -43,6 +50,9 @@ export interface FakeState {
43
50
  staged: string[];
44
51
  refreshes: string[];
45
52
  emails: string[];
53
+ /** `<sha>:<evidence|refused>`, in report order. */
54
+ reports: string[];
55
+ refused: string[];
46
56
  }
47
57
 
48
58
  export function fakeTables(state: DurableObjectState): void {
@@ -66,6 +76,8 @@ export function configureFake(
66
76
  faults?: FakeFault[];
67
77
  /** Other open candidates a base advance rebuilds; `conflicts` ones email their author. */
68
78
  rebaseCandidates?: { changeId: string; pr: number; conflicts?: boolean }[];
79
+ /** `[ancestor, descendant]` commit pairs the fake forge answers `isAncestor` true for. */
80
+ ancestry?: [string, string][];
69
81
  },
70
82
  ): void {
71
83
  fakeTables(state);
@@ -76,6 +88,9 @@ export function configureFake(
76
88
  for (const f of config.faults ?? []) {
77
89
  sql.exec(`INSERT OR REPLACE INTO fake_faults (point, times, fired) VALUES (?, ?, 0)`, f.point, f.times);
78
90
  }
91
+ for (const [ancestor, descendant] of config.ancestry ?? []) {
92
+ sql.exec(`INSERT OR IGNORE INTO fake_ancestry (ancestor, descendant) VALUES (?, ?)`, ancestor, descendant);
93
+ }
79
94
  for (const c of config.rebaseCandidates ?? []) {
80
95
  sql.exec(`INSERT OR REPLACE INTO fake_candidates (change_id, pr, conflicts) VALUES (?, ?, ?)`, c.changeId, c.pr, c.conflicts ? 1 : 0);
81
96
  }
@@ -103,6 +118,8 @@ export function readFakeState(state: DurableObjectState): FakeState {
103
118
  staged: events("stage"),
104
119
  refreshes: events("refresh"),
105
120
  emails: events("email"),
121
+ reports: events("report"),
122
+ refused: events("refused"),
106
123
  };
107
124
  }
108
125
 
@@ -181,9 +198,11 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
181
198
  cause: new ForgeTransportError("timed out reading the body", { transient: true, errorKind: "timeout" }),
182
199
  };
183
200
  }
201
+ const slow = await fires("slow-build");
184
202
  for (let file = 0; file < FAKE_FILE_COUNT; file += 1) {
185
203
  const path = `content/file-${file}.json`;
186
204
  if (resume.done(path)) continue;
205
+ if (slow) await new Promise((resolve) => setTimeout(resolve, SLOW_FILE_MS));
187
206
  if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
188
207
  sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
189
208
  await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
@@ -191,8 +210,17 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
191
210
  return { ok: true, versionId: sha, digest: `digest-${sha}` };
192
211
  },
193
212
 
194
- async evidence() {
195
- return { promotable: true };
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);
196
224
  },
197
225
 
198
226
  async previewPointer() {
@@ -204,8 +232,9 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
204
232
  return sql.exec(`SELECT 1 FROM fake_ancestry WHERE ancestor = ? AND descendant = ?`, ancestor, descendant).toArray().length > 0;
205
233
  },
206
234
 
207
- async advancePreview({ sha, membership, jobId, kind }) {
235
+ async advancePreview({ sha, membership, jobId, kind, releasable }) {
208
236
  setPointer(sha);
237
+ if (!releasable) return;
209
238
  sql.exec(
210
239
  `INSERT INTO fake_advances (sha, membership, job_id, kind) VALUES (?, ?, ?, ?)`,
211
240
  sha,
@@ -2,12 +2,13 @@
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 AND the production preview kinds (integrate / rebuild / revert) over fake
5
+ * synthetic job kinds AND the production preview kinds (integrate / rebuild / revert / sync-preview) over fake
6
6
  * services (`./fake-preview-services.ts`), and answers intents through `submitIntent`.
7
7
  */
8
8
  import type { DurableObjectState } from "cloudflare:workers";
9
9
  import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
10
- 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";
11
12
  import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
12
13
  import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
13
14
  import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
@@ -28,7 +29,11 @@ export class TenantPipeline extends DeployedTenantPipeline {
28
29
  }
29
30
 
30
31
  /** Harness-only: open candidates and arm faults for the fake preview services. */
31
- async configureFake(config: { open?: { changeId: string; headSha: string }[]; faults?: FakeFault[] }): Promise<void> {
32
+ async configureFake(config: {
33
+ open?: { changeId: string; headSha: string }[];
34
+ faults?: FakeFault[];
35
+ ancestry?: [string, string][];
36
+ }): Promise<void> {
32
37
  configureFake(this.ctx, config);
33
38
  }
34
39
 
@@ -91,8 +96,28 @@ interface Env {
91
96
  TENANT_PIPELINE: TenantPipelineNamespace;
92
97
  }
93
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
+
94
117
  export default {
95
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");
96
121
  const match = /^\/intents\/([^/]+)$/.exec(new URL(request.url).pathname);
97
122
  if (request.method !== "POST" || !match) return new Response("not found", { status: 404 });
98
123
  const response = await submitIntent(env.TENANT_PIPELINE, decodeURIComponent(match[1]!), (await request.json()) as PipelineIntent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.2",
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",