Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-core/src/receipts.ts

Snapshot at 34e3175

Containing directory

12,852 bytes · typescriptView raw
1import { claimForAgent, markEntrant } from "./race-entrants";
2import { usageSources } from "./usage";
3import { RpcTarget } from "cloudflare:workers";
4import { Snapshot } from "../../forkhorse-runner/src/snapshot";
5import { loadChecks, MissingChecksError } from "../../forkhorse-runner/src/checks";
6import { receiptJson, requirePassingReceipt, sha256 } from "../../forkhorse-runner/src/runner";
7import type { AuthorizedReview, ReviewAuthority, RunRequest, StoredReceipt } from "../../forkhorse-runner/src/types";
8import { HttpError } from "./http";
9import type { Env, Project } from "./types";
10import type { ClaimReleaseAudit, Fork, Packet, ReceiptSummary, Review } from "./workflow-types";
11
12export const settings = (project: Project) => project.settings ?? { requireReceipt: false };
13export const publicReview = ({ receiptGeneration: _generation, ...review }: Review) => ({
14  ...review, receipt: review.receipt ?? null, returned: review.returned ?? null,
15  usageAtReview: usageSources(review.usageAtReview),
16});
17export type ExecuteReceipt = (
18  request: RunRequest, authority: ReceiptAuthority, generation: string,
19) => Promise<StoredReceipt>;
20const same = (review: Review | undefined, run: AuthorizedReview) =>
21  review && !review.stale && (!review.promoted || run.trigger === "review-created") && review.id === run.reviewId &&
22  review.sha === run.commit && review.repoName === run.forkRepoName &&
23  review.receiptGeneration === run.generation && review.receipt?.trigger === run.trigger;
24
25/** Immutable content-addressed records. Chunks stay well below the 128 KiB value limit,
26 * including worst-case UTF-8 expansion. The manifest is committed with all chunks. */
27export async function storeReceipt(storage: DurableObjectStorage, record: StoredReceipt): Promise<void> {
28  if (!/^[a-f0-9]{64}$/.test(record.digest) || await sha256(receiptJson(record.receipt)) !== record.digest)
29    throw new Error("Invalid receipt digest");
30  const serialized = JSON.stringify(record);
31  if (serialized.length > 8 * 1024 * 1024) throw new Error("Receipt too large");
32  const chunks = Array.from({ length: Math.ceil(serialized.length / 16384) }, (_, i) =>
33    serialized.slice(i * 16384, (i + 1) * 16384));
34  await storage.transaction(async (txn) => {
35    const key = `receipt:${record.digest}`;
36    if (await txn.get(key)) return;
37    for (let i = 0; i < chunks.length; i++) await txn.put(`${key}:${i}`, chunks[i]!);
38    await txn.put(key, { chunks: chunks.length });
39  });
40}
41export async function readReceipt(storage: DurableObjectStorage, digest: string | null): Promise<StoredReceipt | null> {
42  if (!digest || !/^[a-f0-9]{64}$/.test(digest)) return null;
43  const key = `receipt:${digest}`;
44  const manifest = await storage.get<{ chunks: number }>(key);
45  if (!manifest) return null;
46  if (!Number.isInteger(manifest.chunks) || manifest.chunks < 1 || manifest.chunks > 512)
47    throw new Error("Invalid receipt manifest");
48  const chunks: string[] = [];
49  for (let i = 0; i < manifest.chunks; i++) {
50    const chunk = await storage.get<string>(`${key}:${i}`);
51    if (chunk === undefined) throw new Error("Incomplete receipt");
52    chunks.push(chunk);
53  }
54  const record = JSON.parse(chunks.join("")) as StoredReceipt;
55  if (record.digest !== digest || await sha256(receiptJson(record.receipt)) !== digest)
56    throw new Error("Invalid stored receipt");
57  return record;
58}
59
60/** A capability for ONE reserved execution, sent only to the same-script runner.
61 * These callbacks deliberately do not acquire Supervisor's workflow mutex: promote
62 * owns that mutex while awaiting the runner. Attachment uses a storage transaction. */
63export class ReceiptAuthority extends RpcTarget implements ReviewAuthority {
64  #storage: DurableObjectStorage;
65  #run: AuthorizedReview;
66  constructor(storage: DurableObjectStorage, run: AuthorizedReview) {
67    super(); this.#storage = storage; this.#run = structuredClone(run);
68  }
69  async authorize(request: RunRequest): Promise<AuthorizedReview> {
70    if (request.reviewId !== this.#run.reviewId || request.trigger !== this.#run.trigger ||
71        !same(await this.#storage.get<Review>(`review:${request.reviewId}`), this.#run))
72      throw new Error("Stale receipt execution");
73    return structuredClone(this.#run);
74  }
75  async storeReceipt(record: StoredReceipt): Promise<void> {
76    const r = record.receipt, run = this.#run;
77    if (r.projectId !== run.projectId || r.reviewId !== run.reviewId || r.commit !== run.commit ||
78        r.generation !== run.generation || r.trigger !== run.trigger || r.forkRepoName !== run.forkRepoName)
79      throw new Error("Receipt outside capability");
80    await storeReceipt(this.#storage, record);
81  }
82  async setReviewReceiptDigest(run: AuthorizedReview, digest: string): Promise<boolean> {
83    if (Object.keys(this.#run).some(key => run[key as keyof AuthorizedReview] !== this.#run[key as keyof AuthorizedReview])) return false;
84    const record = await readReceipt(this.#storage, digest);
85    if (!record || record.receipt.generation !== run.generation || record.receipt.commit !== run.commit ||
86        record.receipt.reviewId !== run.reviewId || record.receipt.projectId !== run.projectId ||
87        record.receipt.forkRepoName !== run.forkRepoName || record.receipt.trigger !== run.trigger) return false;
88    return this.#storage.transaction(async (txn) => {
89      const review = await txn.get<Review>(`review:${run.reviewId}`);
90      if (!same(review, run) || !review || review.receiptDigest !== null) return false;
91      const claim = await claimForAgent(txn, review.packet, review.agent);
92      // A promoted advisory run may finish after its claim was atomically released,
93      // including after a new claim is made. Only its exact promotion audit restores
94      // that historical binding; same() still fences stale commits and generations.
95      const release = review.promoted && run.trigger === "review-created"
96        ? await txn.get<ClaimReleaseAudit>(`claimRelease:${review.id}`) : undefined;
97      const promotedBinding = release?.releasedBy === "promote" && release.review === review.id &&
98        release.packet === review.packet && release.agent === review.agent && release.repoName === review.repoName;
99      if (!promotedBinding && (!claim || claim.repoName !== review.repoName || claim.agent !== review.agent)) return false;
100      const checks = record.receipt.checks;
101      const status: ReceiptSummary["status"] = !checks.length ? "infrastructure-error"
102        : checks.some(c => c.outcome === "infrastructure-error") ? "infrastructure-error"
103        : checks.some(c => c.outcome === "timed-out") ? "timed-out"
104        : checks.some(c => c.outcome === "failed") ? "failed" : "passed";
105      const finishedAt = new Date().toISOString();
106      review.receiptDigest = digest;
107      review.receipt = {
108        ...review.receipt!, status, digest, finishedAt,
109        durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(review.receipt!.startedAt)),
110        checks: checks.map(c => ({ name: c.check.name, tier: c.check.tier, runtime: c.runtime, message: c.message, outcome: c.outcome, durationMs: c.durationMs })),
111      };
112      await txn.put(`review:${review.id}`, review);
113      if (status === "passed") await markEntrant(txn, review, "receiptPassedAt", finishedAt);
114      return true;
115    });
116  }
117}
118
119/** Invoked only after project/caller/claim/fork/head authorization in Workflow. */
120export class Receipts {
121  constructor(private storage: DurableObjectStorage, private env: Env,
122    private execute: ExecuteReceipt, private background: (job: Promise<void>) => void) {}
123
124  async reserve(project: Project, review: Review, fork: Fork, trigger: RunRequest["trigger"]): Promise<AuthorizedReview> {
125    if (fork.repoName === project.repoName || fork.repoName !== review.repoName || fork.agent !== review.agent ||
126        await this.storage.get(`repo:${fork.repoName}`) !== review.agent)
127      throw new Error("Receipt requires an authorized fork");
128    const run: AuthorizedReview = {
129      reviewId: review.id, trigger, projectId: project.id, forkRepoName: fork.repoName,
130      forkRemote: fork.remote, commit: review.sha, generation: crypto.randomUUID(),
131    };
132    review.receiptGeneration = run.generation;
133    review.receiptDigest = null;
134    review.receipt = {
135      status: "running", trigger, digest: null, startedAt: new Date().toISOString(),
136      finishedAt: null, durationMs: null, checks: [],
137    };
138    if ((await this.storage.get<Packet>(`packet:${review.packet}`))?.mode === "race") {
139      await this.storage.transaction(async txn => {
140        await txn.put(`review:${review.id}`, review);
141        if (trigger === "review-created")
142          await markEntrant(txn, review, "reviewRequestedAt", review.createdAt);
143      });
144    } else await this.storage.put(`review:${review.id}`, review);
145    return run;
146  }
147  start(run: AuthorizedReview, advisory = false): void {
148    this.background(this.run(run, advisory).then(() => {}));
149  }
150  async run(run: AuthorizedReview, advisory = false): Promise<StoredReceipt | null> {
151    try {
152      const authority = new ReceiptAuthority(this.storage, run);
153      await authority.authorize(run);
154      using repo = await this.env.ARTIFACTS.get(run.forkRepoName);
155      const checks = await loadChecks(repo, run.commit);
156      await this.storage.transaction(async txn => {
157        const review = await txn.get<Review>(`review:${run.reviewId}`);
158        if (!same(review, run) || !review) throw new Error("Stale receipt execution");
159        review.receipt!.checks = checks.map(c => ({ name: c.name, tier: c.tier, outcome: "running", durationMs: null }));
160        await txn.put(`review:${review.id}`, review);
161      });
162      return await this.execute({ reviewId: run.reviewId, trigger: run.trigger }, authority, run.generation);
163    } catch (error) {
164      // Missing checks/images, startup and cleanup errors have no publishable receipt.
165      // Never expose provider exceptions, tokens, or commands in the state projection.
166      await this.storage.transaction(async txn => {
167        const review = await txn.get<Review>(`review:${run.reviewId}`);
168        if (!same(review, run) || !review || review.receiptDigest) return;
169        if (advisory && error instanceof MissingChecksError) {
170          review.receipt = null;
171          review.receiptGeneration = undefined;
172          await txn.put(`review:${review.id}`, review);
173          return;
174        }
175        const finishedAt = new Date().toISOString();
176        review.receipt = { ...review.receipt!, status: "infrastructure-error", finishedAt,
177          durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(review.receipt!.startedAt)),
178          checks: review.receipt!.checks.map(c => ({ ...c, outcome: "infrastructure-error" })),
179        };
180        await txn.put(`review:${review.id}`, review);
181      });
182      return null;
183    }
184  }
185  async requirePassing(project: Project, review: Review): Promise<void> {
186    try {
187      if (!settings(project).requireReceipt) return;
188      using repo = await this.env.ARTIFACTS.get(review.repoName);
189      const checks = await loadChecks(repo, review.sha);
190      const moduleDigests: Record<string, string> = {};
191      for (const check of checks) if (check.tier === "quick") {
192        const snapshot = new Snapshot(repo, review.sha);
193        try {
194          const source = await snapshot.read(check.module);
195          if (source === null) throw new Error("Quick module missing");
196          moduleDigests[check.module] = `module:sha256:${await sha256(source)}`;
197        } finally { snapshot.close(); }
198      }
199      await requirePassingReceipt(await readReceipt(this.storage, review.receiptDigest), {
200        projectId: project.id, reviewId: review.id, forkRepoName: review.repoName, commit: review.sha,
201        generation: review.receiptGeneration ?? "", digest: review.receiptDigest,
202        checks, moduleDigests, requireReceipt: true,
203      });
204    } catch { throw new HttpError(409, "A current passing check receipt is required."); }
205  }
206}
207
208/** A terminated execution is never silently resumed or presented as passing. */
209export async function recoverInterruptedReceipts(storage: DurableObjectStorage): Promise<void> {
210  await storage.transaction(async txn => {
211    for (const review of (await txn.list<Review>({ prefix: "review:" })).values()) {
212      if (review.receipt?.status !== "running") continue;
213      const finishedAt = new Date().toISOString();
214      review.receiptGeneration = undefined;
215      review.receiptDigest = null;
216      review.receipt = { ...review.receipt, status: "infrastructure-error", digest: null, finishedAt,
217        durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(review.receipt.startedAt)),
218        checks: review.receipt.checks.map(check => ({ ...check, outcome: "infrastructure-error" })),
219      };
220      await txn.put(`review:${review.id}`, review);
221    }
222  });
223}
224