import { env } from "cloudflare:workers"; import { first } from "../db"; import { repoDb, stmt } from "../scoped-db"; import { registerLocators, type LocatorKind } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { rememberScope, runDb } from "./scope"; import type { CompiledJob, CompileInput } from "./compile"; import type { Trigger } from "./triggers"; import type { RepoRow } from "~/lib/types"; export async function allocateRunNumber(repoId: string): Promise { const row = await first<{ run_number: number }>( "UPDATE repos SET next_run_number = next_run_number + 1 WHERE id = ? RETURNING next_run_number - 1 AS run_number", repoId, ); if (!row) throw new Error("Repository disappeared while scheduling workflows"); return row.run_number; } export type RunInsert = { repo: RepoRow; trigger: Trigger; path: string; workflowName: string; runId: string; runNumber: number; jobs: CompiledJob[]; error: string | null; approvalRequired: boolean; source: string | null; context: CompileInput | null; rerunOf: string | null; }; /** Returns the generated job ids keyed by job key. */ export async function insertRun(input: RunInsert): Promise> { const now = Date.now(); const { repo, trigger } = input; const statements = [ stmt( `INSERT INTO workflow_runs (id, repo_id, source_repo_id, run_number, workflow_path, workflow_name, event, ref, sha, title, actor_id, pr_number, approval_required, status, conclusion, created_at, completed_at, workflow_source, context, trigger_depth, rerun_of, untrusted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.runId, repo.id, trigger.sourceRepo.id, input.runNumber, input.path, input.workflowName, trigger.event, trigger.ref, trigger.sha, input.error ? `Invalid workflow: ${input.error}`.slice(0, 500) : trigger.title, trigger.actorId, trigger.prNumber, input.approvalRequired ? 1 : 0, input.error ? "completed" : "queued", input.error ? "failure" : null, now, input.error ? now : null, input.source, input.context ? JSON.stringify(input.context) : null, trigger.depth ?? 0, input.rerunOf, trigger.untrusted ? 1 : 0, ), ]; const ids = new Map(); for (const job of input.jobs) { const jobId = scopedId(repo.id); ids.set(job.key, jobId); statements.push( stmt( `INSERT INTO workflow_jobs (id, run_id, repo_id, job_key, name, runs_on, needs, spec, environment, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`, jobId, input.runId, repo.id, job.key, job.name, JSON.stringify(job.runsOn), JSON.stringify(job.needs), JSON.stringify(job), job.environment?.name ?? null, now, ), ); for (const step of job.steps) { statements.push( stmt( "INSERT INTO job_steps (job_id, idx, name, status) VALUES (?, ?, ?, 'pending')", jobId, step.index, step.name, ), ); } } await repoDb(repo.id).batch(statements); await registerLocators([ ["workflow_run", input.runId, repo.id], ...[...ids.values()].map((jobId): [LocatorKind, string, string] => ["workflow_job", jobId, repo.id]), ]); rememberScope("workflow_run", input.runId, repo.id); for (const jobId of ids.values()) rememberScope("workflow_job", jobId, repo.id); return ids; } /** A new push to a fork PR replaces its older unapproved runs so one author can't fill the approval queue. */ export async function supersedePendingApprovals(repoId: string, prNumber: number, workflowPath: string): Promise { const now = Date.now(); await repoDb(repoId).batch([ stmt( `UPDATE workflow_jobs SET status = 'completed', conclusion = 'cancelled', completed_at = ? WHERE run_id IN (SELECT id FROM workflow_runs WHERE repo_id = ? AND pr_number = ? AND workflow_path = ? AND approval_required = 1 AND status = 'queued')`, now, repoId, prNumber, workflowPath, ), stmt( `UPDATE workflow_runs SET status = 'completed', conclusion = 'cancelled', completed_at = ? WHERE repo_id = ? AND pr_number = ? AND workflow_path = ? AND approval_required = 1 AND status = 'queued'`, now, repoId, prNumber, workflowPath, ), ]); } /** * Re-running failed jobs: jobs that succeeded in the previous attempt are copied as completed (with their * outputs) so only failed jobs and their dependents execute again. */ export async function reuseSuccessfulJobs(previousRunId: string, ids: Map): Promise { const db = await runDb(previousRunId); if (!db) return []; const previous = await db.all<{ id: string; job_key: string; conclusion: string; outputs: string | null; runner_name: string | null; started_at: number | null; completed_at: number | null; }>( `SELECT id, job_key, conclusion, outputs, runner_name, started_at, completed_at FROM workflow_jobs WHERE run_id = ? AND status = 'completed' AND conclusion IN ('success', 'skipped')`, previousRunId, ); const statements = []; const reused: string[] = []; const copies: Array<[string, string]> = []; for (const row of previous) { const jobId = ids.get(row.job_key); if (!jobId) continue; reused.push(row.job_key); copies.push([row.id, jobId]); statements.push( stmt( `UPDATE workflow_jobs SET status = 'completed', conclusion = ?, outputs = ?, runner_name = ?, started_at = ?, completed_at = ? WHERE id = ?`, row.conclusion, row.outputs, row.runner_name ? `${row.runner_name} (previous attempt)` : "previous attempt", row.started_at, row.completed_at, jobId, ), stmt( `UPDATE job_steps SET status = 'completed', started_at = prev.started_at, completed_at = prev.completed_at, conclusion = prev.conclusion FROM (SELECT idx, started_at, completed_at, conclusion FROM job_steps WHERE job_id = ?) AS prev WHERE job_steps.job_id = ? AND job_steps.idx = prev.idx`, row.id, jobId, ), ); } await db.batch(statements); await Promise.all( copies.map(async ([from, to]) => { const log = await env.BUCKET.get(`logs/${from}.json`); if (log) await env.BUCKET.put(`logs/${to}.json`, await log.arrayBuffer(), { httpMetadata: log.httpMetadata }); }), ); return reused; }