Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

59 lines · 3.1 KBRaw History
1import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
2import type { Env } from "../env.js";
3import { AUTHORED_PAGE, assembleExport, exportAccount, exportAuthored, exportRepository, failExport, startExport, type ExportJob } from "./export.js";
4import { deleteAccountObjects, destroyRepository, ghostPage, type DeletionCleanup } from "./lifecycle.js";
5import { plainSteps } from "../lib/rpc.js";
6
7export type AccountJob = ExportJob | DeletionCleanup;
8
9const RETRY = { retries: { limit: 5, delay: "10 seconds", backoff: "exponential" }, timeout: "15 minutes" } as const;
10/** Bundling a large repository, or putting a large archive together, takes longer. */
11const LONG = { ...RETRY, timeout: "60 minutes" } as const;
12/** An overall cap on paged steps, so a broken page can't run forever (enough for 100,000 repositories). */
13const MAX_PAGES = 4000;
14
15/**
16 * The background part of the account lifecycle (accounts/): building an export (instance `export-<id>`), and
17 * finishing a deletion (instance `delete-<account id>`): wiping the deleted repositories, the account's own
18 * R2 objects, and attributing its work in every other repository to the ghost. Each step is retried on
19 * failure; an export that still fails is marked failed with the reason.
20 */
21export class AccountWorkflow extends WorkflowEntrypoint<Env, AccountJob> {
22 override async run(event: WorkflowEvent<AccountJob>, rpcStep: WorkflowStep): Promise<unknown> {
23 const step = plainSteps(rpcStep);
24 const job = event.payload;
25 if (job.kind === "export") return this.export(job.exportId, step);
26 return this.cleanup(job, step);
27 }
28
29 private async export(id: string, step: WorkflowStep): Promise<unknown> {
30 try {
31 const plan = await step.do("start", RETRY, () => startExport(this.env, id));
32 await step.do("account", RETRY, () => exportAccount(this.env, id));
33 for (const repoId of plan.owned) await step.do(`repository ${repoId}`, LONG, () => exportRepository(this.env, id, repoId));
34 for (let i = 0; i < plan.visible.length; i += AUTHORED_PAGE) {
35 const page = plan.visible.slice(i, i + AUTHORED_PAGE);
36 await step.do(`authored ${i}`, RETRY, () => exportAuthored(this.env, id, page));
37 }
38 await step.do("assemble", LONG, () => assembleExport(this.env, id));
39 return { ready: true };
40 } catch (e) {
41 const message = e instanceof Error ? e.message : String(e);
42 await step.do("fail", RETRY, () => failExport(this.env, id, message));
43 return { failed: message };
44 }
45 }
46
47 private async cleanup(job: DeletionCleanup, step: WorkflowStep): Promise<unknown> {
48 for (const repoId of job.repoIds) await step.do(`destroy ${repoId}`, LONG, () => destroyRepository(this.env, repoId));
49 for (let i = 0; i < 10; i++) {
50 if (await step.do(`objects ${i}`, RETRY, () => deleteAccountObjects(this.env, job.accountId))) break;
51 }
52 let after: string | null = "";
53 for (let i = 0; after !== null && i < MAX_PAGES; i++) {
54 const from: string = after;
55 after = await step.do(`ghost ${i}`, RETRY, () => ghostPage(this.env, job, from));
56 }
57 return { done: true };
58 }
59}