import { env } from "cloudflare:workers"; import { repoDb, stmt } from "../scoped-db"; import { randomId } from "../crypto"; import { CAPS, numberVar } from "../limits"; import { loadRepoById } from "../repos"; import { isScratchRepo } from "../sites/config"; import { branchSha, listRefs } from "../git/refs"; import { minimumInterval, nextFireTime, parseCron } from "./cron"; import { readWorkflowFiles, scheduleWorkflows } from "./scheduler"; import { parseWorkflow } from "./spec"; import type { RepoRow } from "~/lib/types"; const DEFAULT_MIN_INTERVAL_MINUTES = 15; const MINUTE_MS = 60_000; const DUE_BATCH = 25; /** Like GitHub, schedules pause on repositories without pushes for 60 days. */ const INACTIVE_MS = 60 * 24 * 60 * MINUTE_MS; type ScheduleRow = { id: string; repo_id: string; workflow_path: string; cron: string; next_run_at: number }; function minIntervalMs(): number { return numberVar(env.MIN_SCHEDULE_INTERVAL_MINUTES, DEFAULT_MIN_INTERVAL_MINUTES) * MINUTE_MS; } /** Validates a cron entry against the frequency limit; returns its next fire time or an error. */ export function checkSchedule(cron: string, now: number): { next: number } | { error: string } { try { const schedule = parseCron(cron); const next = nextFireTime(schedule, now); if (next === null) return { error: "never fires" }; if (minimumInterval(schedule, now) < minIntervalMs()) { return { error: `fires more often than every ${minIntervalMs() / MINUTE_MS} minutes` }; } return { next }; } catch (error) { return { error: error instanceof Error ? error.message : "invalid cron" }; } } /** * Indexes the default branch's workflow triggers and schedules into the repository's RepoState so repository events and the cron * handler can find interested workflows without reading git. */ export async function indexWorkflows(repo: RepoRow, sha: string): Promise { const files = await readWorkflowFiles(repo, sha); const now = Date.now(); const triggers: Array<{ path: string; name: string; events: string[] }> = []; const schedules: Array<{ path: string; cron: string; next: number }> = []; for (const file of files) { const parsed = file.tooLarge ? null : parseWorkflow(file.source); if (!parsed?.ok) continue; triggers.push({ path: file.path, name: parsed.spec.name ?? file.path, events: Object.keys(parsed.spec.on) }); for (const entry of parsed.spec.on.schedule ?? []) { const checked = checkSchedule(entry.cron, now); if ("next" in checked && schedules.length < CAPS.schedulesPerRepo) { schedules.push({ path: file.path, cron: entry.cron, next: checked.next }); } } } const db = repoDb(repo.id); const existing = await db.all<{ workflow_path: string; cron: string }>( "SELECT workflow_path, cron FROM workflow_schedules WHERE repo_id = ?", repo.id, ); const wanted = new Set(schedules.map((entry) => `${entry.path}\n${entry.cron}`)); const statements = [stmt("DELETE FROM workflow_triggers WHERE repo_id = ?", repo.id)]; for (const trigger of triggers) { statements.push( stmt( `INSERT INTO workflow_triggers (repo_id, workflow_path, workflow_name, events, updated_at) VALUES (?, ?, ?, ?, ?)`, repo.id, trigger.path, trigger.name, JSON.stringify(trigger.events), now, ), ); } for (const row of existing.filter((candidate) => !wanted.has(`${candidate.workflow_path}\n${candidate.cron}`))) { statements.push( stmt( "DELETE FROM workflow_schedules WHERE repo_id = ? AND workflow_path = ? AND cron = ?", repo.id, row.workflow_path, row.cron, ), ); } for (const entry of schedules) { statements.push( stmt( `INSERT OR IGNORE INTO workflow_schedules (id, repo_id, workflow_path, cron, next_run_at) VALUES (?, ?, ?, ?, ?)`, randomId(), repo.id, entry.path, entry.cron, entry.next, ), ); } await db.batch(statements); } export async function hasTriggerFor(repoId: string, event: string): Promise { const row = await repoDb(repoId).first<{ found: number }>( `SELECT 1 AS found FROM workflow_triggers WHERE repo_id = ? AND EXISTS (SELECT 1 FROM json_each(events) WHERE value = ?) LIMIT 1`, repoId, event, ); return row !== null; } async function fire(row: ScheduleRow): Promise { const repo = await loadRepoById(row.repo_id); if (!repo || repo.has_actions !== 1 || repo.is_archived === 1 || repo.owner_disabled_at || isScratchRepo(repo)) return; if (Date.now() - (repo.pushed_at ?? repo.created_at) > INACTIVE_MS) return; const sha = branchSha(await listRefs(repo), repo.default_branch); if (!sha) return; await scheduleWorkflows(repo, { event: "schedule", action: null, ref: `refs/heads/${repo.default_branch}`, sha, actorId: null, actorName: "schedule", title: `Scheduled: ${row.cron}`, prNumber: null, headRef: null, baseRef: null, changedPaths: null, inputs: {}, workflowPath: row.workflow_path, sourceRepo: repo, cron: row.cron, }); } /** * Fires the repository's due schedules, claiming each by advancing `next_run_at` first. Run by the RepoState * alarm at the earliest `next_run_at` (repo-state/housekeeping.ts), so no cron scans every repository. */ export async function fireDueSchedulesForRepo(repoId: string, now: number): Promise { const due = await repoDb(repoId).all( "SELECT id, repo_id, workflow_path, cron, next_run_at FROM workflow_schedules WHERE next_run_at <= ? ORDER BY next_run_at LIMIT ?", now, DUE_BATCH, ); for (const row of due) { const db = repoDb(row.repo_id); const checked = checkSchedule(row.cron, now); if (!("next" in checked)) { await db.run("DELETE FROM workflow_schedules WHERE id = ?", row.id); continue; } const claimed = await db.run( "UPDATE workflow_schedules SET next_run_at = ?, last_run_at = ? WHERE id = ? AND next_run_at = ?", checked.next, now, row.id, row.next_run_at, ); if (claimed.meta.changes !== 1) continue; try { await fire(row); } catch (error) { console.error("Scheduled workflow failed to start", row.id, error); } } } export async function indexRepoWorkflows(repoId: string, sha: string): Promise { const repo = await loadRepoById(repoId); if (!repo || repo.has_actions !== 1 || isScratchRepo(repo) || /^0+$/.test(sha)) return; await indexWorkflows(repo, sha); }