CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
scheduled-workflows.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 665c8bf | 1 | /** |
| 2 | * Scheduled workflows — fires `on: schedule` triggers from the autopilot | |
| 3 | * tick. | |
| 4 | * | |
| 5 | * Pipeline (per tick, default every 5 minutes via src/lib/autopilot.ts): | |
| 6 | * | |
| 7 | * 1. Select all non-disabled workflows whose serialised `parsed` JSON | |
| 8 | * includes a non-empty `schedules` array. (Cheap LIKE filter — DB | |
| 9 | * doesn't natively know about JSON keys here, and we want to avoid | |
| 10 | * pulling all workflows on every tick.) | |
| 11 | * 2. For each workflow: | |
| 12 | * - Look up the latest schedule-triggered run (event="schedule"). | |
| 13 | * That row's queuedAt is the `since` boundary; absent → use | |
| 14 | * (now - 6 minutes), so a freshly-imported workflow doesn't | |
| 15 | * back-fire for hours of crons. | |
| 16 | * - For each cron string, parse via src/lib/cron.ts and ask | |
| 17 | * `cronFiredBetween(since, now)`. The first cron that fired | |
| 18 | * wins — we enqueue exactly one run per workflow per tick. | |
| 19 | * 3. enqueueRun(...) with event="schedule", ref=defaultBranch, | |
| 20 | * commitSha=resolved-default-branch-HEAD. The existing runner | |
| 21 | * (src/lib/workflow-runner.ts) picks it up exactly like a manual | |
| 22 | * run. | |
| 23 | * | |
| 24 | * Fail-open: every step swallows DB errors and returns a result object | |
| 25 | * so the autopilot ticker never wedges. Returns a per-call summary so | |
| 26 | * callers (e.g. the admin dashboard) can show "last tick fired N runs." | |
| 27 | * | |
| 28 | * Safety guard: caps each tick at MAX_RUNS_PER_TICK so a misconfigured | |
| 29 | * cron and an empty schedule-runs table cannot stampede the queue. | |
| 30 | */ | |
| 31 | ||
| 32 | import { and, desc, eq, isNull, sql } from "drizzle-orm"; | |
| 33 | import { db } from "../db"; | |
| 34 | import { | |
| 35 | workflowRuns, | |
| 36 | workflows, | |
| 37 | repositories, | |
| 38 | } from "../db/schema"; | |
| 39 | import { parseCron, cronFiredBetween } from "./cron"; | |
| 40 | import { enqueueRun } from "./workflow-runner"; | |
| 41 | import { resolveRef, getDefaultBranch } from "../git/repository"; | |
| 42 | ||
| 43 | export const MAX_RUNS_PER_TICK = 50; | |
| 44 | const SINCE_FALLBACK_MS = 6 * 60_000; // 6 min — slightly > default 5-min tick | |
| 45 | ||
| 46 | export type ScheduledTickResult = { | |
| 47 | considered: number; | |
| 48 | fired: number; | |
| 49 | errors: number; | |
| 50 | }; | |
| 51 | ||
| 52 | type WorkflowRow = { | |
| 53 | id: string; | |
| 54 | repositoryId: string; | |
| 55 | parsed: string; | |
| 56 | }; | |
| 57 | ||
| 58 | /** | |
| 59 | * Parse the `parsed` JSON column and return the cron expressions, or []. | |
| 60 | * Defensive — never throws. | |
| 61 | */ | |
| 62 | export function schedulesFromParsedJson(parsedJson: string): string[] { | |
| 63 | try { | |
| 64 | const obj = JSON.parse(parsedJson || "{}"); | |
| 65 | const out = Array.isArray(obj?.schedules) ? obj.schedules : []; | |
| 66 | return out.filter((s: unknown): s is string => typeof s === "string" && s.trim().length > 0); | |
| 67 | } catch { | |
| 68 | return []; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Pure decision helper — given a workflow's schedules + last fire wall + | |
| 74 | * current wall, return the first cron that should fire (or null). | |
| 75 | * Exposed for unit tests so the cron→fire wiring is verifiable without | |
| 76 | * a DB. | |
| 77 | */ | |
| 78 | export function firstCronToFire( | |
| 79 | schedules: string[], | |
| 80 | since: Date, | |
| 81 | until: Date | |
| 82 | ): string | null { | |
| 83 | for (const expr of schedules) { | |
| 84 | const parsed = parseCron(expr); | |
| 85 | if (!parsed.ok) continue; | |
| 86 | if (cronFiredBetween(parsed.cron, since, until)) return expr; | |
| 87 | } | |
| 88 | return null; | |
| 89 | } | |
| 90 | ||
| 91 | async function lastScheduleRunQueuedAt( | |
| 92 | workflowId: string | |
| 93 | ): Promise<Date | null> { | |
| 94 | try { | |
| 95 | const [row] = await db | |
| 96 | .select({ queuedAt: workflowRuns.queuedAt }) | |
| 97 | .from(workflowRuns) | |
| 98 | .where( | |
| 99 | and( | |
| 100 | eq(workflowRuns.workflowId, workflowId), | |
| 101 | eq(workflowRuns.event, "schedule") | |
| 102 | ) | |
| 103 | ) | |
| 104 | .orderBy(desc(workflowRuns.queuedAt)) | |
| 105 | .limit(1); | |
| 106 | return row ? new Date(row.queuedAt) : null; | |
| 107 | } catch { | |
| 108 | return null; | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | async function loadOwnerAndRepoName( | |
| 113 | repositoryId: string | |
| 114 | ): Promise<{ ownerName: string; repoName: string; defaultBranch: string } | null> { | |
| 115 | try { | |
| 116 | const [row] = await db | |
| 117 | .select({ | |
| 118 | repoName: repositories.name, | |
| 119 | ownerId: repositories.ownerId, | |
| 120 | defaultBranch: repositories.defaultBranch, | |
| 121 | }) | |
| 122 | .from(repositories) | |
| 123 | .where(eq(repositories.id, repositoryId)) | |
| 124 | .limit(1); | |
| 125 | if (!row) return null; | |
| 126 | // Owner username lookup via the standard repositories.owner_id → users | |
| 127 | // join. Performed lazily to avoid a join on the hot list query. | |
| 128 | const { users } = await import("../db/schema"); | |
| 129 | const [owner] = await db | |
| 130 | .select({ username: users.username }) | |
| 131 | .from(users) | |
| 132 | .where(eq(users.id, row.ownerId)) | |
| 133 | .limit(1); | |
| 134 | if (!owner) return null; | |
| 135 | return { | |
| 136 | ownerName: owner.username, | |
| 137 | repoName: row.repoName, | |
| 138 | defaultBranch: row.defaultBranch || "main", | |
| 139 | }; | |
| 140 | } catch { | |
| 141 | return null; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Walk every non-disabled workflow whose parsed JSON could include a | |
| 147 | * schedules field, decide if any cron fired since the last schedule-run, | |
| 148 | * and enqueue at most one run per workflow per tick. | |
| 149 | */ | |
| 150 | export async function runScheduledWorkflowsTick( | |
| 151 | now: Date = new Date() | |
| 152 | ): Promise<ScheduledTickResult> { | |
| 153 | const result: ScheduledTickResult = { considered: 0, fired: 0, errors: 0 }; | |
| 154 | ||
| 155 | let candidates: WorkflowRow[] = []; | |
| 156 | try { | |
| 157 | candidates = await db | |
| 158 | .select({ | |
| 159 | id: workflows.id, | |
| 160 | repositoryId: workflows.repositoryId, | |
| 161 | parsed: workflows.parsed, | |
| 162 | }) | |
| 163 | .from(workflows) | |
| 164 | .where( | |
| 165 | and( | |
| 166 | eq(workflows.disabled, false), | |
| 167 | // Cheap pre-filter: only workflows whose parsed JSON contains | |
| 168 | // the literal token "schedules" (presence implies non-empty | |
| 169 | // array via the parser contract). This is intentionally a | |
| 170 | // string-LIKE — JSON-aware operators are nice-to-have. | |
| 171 | sql`${workflows.parsed} LIKE '%"schedules"%'` | |
| 172 | ) | |
| 173 | ); | |
| 174 | } catch { | |
| 175 | candidates = []; | |
| 176 | result.errors += 1; | |
| 177 | } | |
| 178 | ||
| bf19c50 | 179 | // Defensive: if a leaked mock or DB driver returns a non-iterable, coerce |
| 180 | // to an empty array so `for (const w of candidates)` doesn't throw. This | |
| 181 | // closes the cross-suite test-pollution failure where another test file's | |
| 182 | // mock.module("../db", ...) leaked into this code path. | |
| 183 | if (!Array.isArray(candidates)) { | |
| 184 | candidates = []; | |
| 185 | result.errors += 1; | |
| 186 | } | |
| 187 | ||
| 665c8bf | 188 | for (const w of candidates) { |
| 189 | if (result.fired >= MAX_RUNS_PER_TICK) break; | |
| 190 | result.considered += 1; | |
| 191 | ||
| 192 | const schedules = schedulesFromParsedJson(w.parsed); | |
| 193 | if (schedules.length === 0) continue; | |
| 194 | ||
| 195 | const lastQ = await lastScheduleRunQueuedAt(w.id); | |
| 196 | const since = lastQ | |
| 197 | ? lastQ | |
| 198 | : new Date(now.getTime() - SINCE_FALLBACK_MS); | |
| 199 | ||
| 200 | const expr = firstCronToFire(schedules, since, now); | |
| 201 | if (!expr) continue; | |
| 202 | ||
| 203 | const repoMeta = await loadOwnerAndRepoName(w.repositoryId); | |
| 204 | if (!repoMeta) { | |
| 205 | result.errors += 1; | |
| 206 | continue; | |
| 207 | } | |
| 208 | ||
| 209 | let commitSha: string | null = null; | |
| 210 | try { | |
| 211 | commitSha = await resolveRef( | |
| 212 | repoMeta.ownerName, | |
| 213 | repoMeta.repoName, | |
| 214 | repoMeta.defaultBranch | |
| 215 | ); | |
| 216 | } catch { | |
| 217 | commitSha = null; | |
| 218 | } | |
| 219 | if (!commitSha) { | |
| 220 | // Try to recover the default branch via the on-disk repo if the DB | |
| 221 | // value is stale — best-effort. If still unknown, skip. | |
| 222 | try { | |
| 223 | const def = await getDefaultBranch( | |
| 224 | repoMeta.ownerName, | |
| 225 | repoMeta.repoName | |
| 226 | ); | |
| 227 | if (def) { | |
| 228 | commitSha = await resolveRef( | |
| 229 | repoMeta.ownerName, | |
| 230 | repoMeta.repoName, | |
| 231 | def | |
| 232 | ); | |
| 233 | } | |
| 234 | } catch { | |
| 235 | commitSha = null; | |
| 236 | } | |
| 237 | } | |
| 238 | if (!commitSha) { | |
| 239 | result.errors += 1; | |
| 240 | continue; | |
| 241 | } | |
| 242 | ||
| 243 | try { | |
| 244 | await enqueueRun({ | |
| 245 | workflowId: w.id, | |
| 246 | repositoryId: w.repositoryId, | |
| 247 | event: "schedule", | |
| 248 | ref: `refs/heads/${repoMeta.defaultBranch}`, | |
| 249 | commitSha, | |
| 250 | triggeredBy: null, | |
| 251 | }); | |
| 252 | result.fired += 1; | |
| 253 | } catch { | |
| 254 | result.errors += 1; | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | return result; | |
| 259 | } | |
| 260 | ||
| 261 | /** Test-only exposed internals so DB-less test cases can pin behaviour. */ | |
| 262 | export const __test = { | |
| 263 | schedulesFromParsedJson, | |
| 264 | firstCronToFire, | |
| 265 | SINCE_FALLBACK_MS, | |
| 266 | }; |