import { WorkflowEntrypoint } from "cloudflare:workers"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; import { runDb } from "./scope"; import { loadRepoById } from "../repos"; import { emitWebhook, publicUrl } from "../events/emit"; import { cancelActiveJob, completeJob } from "./jobs"; import type { WorkflowRunRow } from "./jobs"; import { expireStaleJobs, loadStates, scheduleTick } from "./dispatch"; import type { JobState } from "./dispatch"; import { deploySitesForRun } from "../sites/triggers"; import { notifyRunCompleted } from "../notify/ci"; import type { Conclusion } from "~/lib/types"; export type CiRunParams = { runId: string }; const WAIT_TIMEOUT = "5 minutes"; const GATED_WAIT_TIMEOUT_SECONDS = 60 * 60; const MAX_ITERATIONS = 1000; const CAPACITY_RETRY = "1 minute"; function runConclusion(jobs: JobState[]): Conclusion { if (jobs.length === 0) return "skipped"; if (jobs.some((job) => job.conclusion === "failure")) return "failure"; if (jobs.some((job) => job.conclusion === "cancelled")) return "cancelled"; if (jobs.every((job) => job.conclusion === "skipped")) return "skipped"; return "success"; } /** Gated jobs wake on review events; wait timers wake at their deadline. */ function gatedTimeout(wakeAt: number | null): `${number} seconds` { const seconds = wakeAt ? Math.ceil((wakeAt - Date.now()) / 1000) : GATED_WAIT_TIMEOUT_SECONDS; return `${Math.min(GATED_WAIT_TIMEOUT_SECONDS, Math.max(1, seconds))} seconds`; } /** Durable orchestration of one workflow run: dispatches jobs as their `needs` resolve and waits for completion events. */ export class CiRunWorkflow extends WorkflowEntrypoint { override async run(event: Readonly>, step: WorkflowStep): Promise { const { runId } = event.payload; const started = await step.do("start", async () => { const db = await runDb(runId); const row = await db?.first<{ status: string }>("SELECT status FROM workflow_runs WHERE id = ?", runId); if (!db || !row || row.status === "completed") return false; await db.run("UPDATE workflow_runs SET status = 'in_progress', started_at = ? WHERE id = ?", Date.now(), runId); return true; }); if (!started) return; for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { const progress = await step.do(`schedule-${iteration}`, () => scheduleTick(runId)); if (progress.active === 0) break; if (progress.deferred > 0 && progress.dispatched === 0) { await step.sleep(`capacity-${iteration}`, CAPACITY_RETRY); continue; } const onlyGated = progress.gated > 0 && progress.dispatched === 0 && progress.deferred === 0; const timeout = onlyGated ? gatedTimeout(progress.wakeAt) : WAIT_TIMEOUT; try { await step.waitForEvent(`wait-${iteration}`, { type: "job-done", timeout }); } catch { await step.do(`expire-${iteration}`, () => expireStaleJobs(runId)); } } await step.do("finish", async () => { for (const job of await loadStates(runId)) { if (job.status === "completed") continue; if (job.cloud && job.status === "in_progress") await cancelActiveJob(job.row, "workflow ended"); else await completeJob(job.id, "cancelled"); } const jobs = await loadStates(runId); const conclusion = runConclusion(jobs); const db = await runDb(runId); if (!db) return; await db.run( "UPDATE workflow_runs SET status = 'completed', conclusion = ?, completed_at = ? WHERE id = ? AND status != 'completed'", conclusion, Date.now(), runId, ); const runRow = await db.first("SELECT * FROM workflow_runs WHERE id = ?", runId); const repo = runRow ? await loadRepoById(runRow.repo_id) : null; if (!runRow || !repo) return; const url = publicUrl(`/${repo.owner_name}/${repo.name}/actions/runs/${runRow.run_number}`); await emitWebhook(repo.id, "workflow_run", { action: "completed", workflow_run: { id: runRow.id, run_number: runRow.run_number, name: runRow.workflow_name, event: runRow.event, head_sha: runRow.sha, head_branch: runRow.ref.replace(/^refs\/heads\//, ""), status: "completed", conclusion, html_url: url, }, }); await notifyRunCompleted(repo, runRow, conclusion); }); await step.do("sites", () => deploySitesForRun(runId)); } }