Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit665c8bfunknown_key

feat(workflows): cron-driven scheduled workflows

feat(workflows): cron-driven scheduled workflows

Workflows can now declare an `on: schedule` trigger with one or more
cron expressions. The autopilot ticker walks them every 5 minutes
(default) and enqueues a run when any cron has fired since the last
schedule-triggered run.

Example:
    on:
      schedule:
        - cron: "0 * * * *"      # every hour
        - cron: "30 9 * * 1-5"   # weekdays at 09:30 UTC

Pure helpers:
- src/lib/cron.ts — 5-field UNIX cron parser + matcher.
  Supports literals, ranges, steps (a-b/n, */n), comma-lists, full
  POSIX OR semantics for dom + dow. Rejects @aliases and L/W/#/?
  with a clear error rather than silently never firing. Pure module,
  zero side effects.
  - parseCron(expr) → {ok:true, cron} | {ok:false, error}
  - cronMatches(cron, date) → boolean
  - cronFiredBetween(cron, since, until) → boolean (caps at 1 day so
    a misconfigured `since` from 2010 cannot blow up).

Parser extension (additive — does not break the locked contract):
- src/lib/workflow-parser.ts ParsedWorkflow gains an optional
  `schedules?: string[]` field. New helper `extractSchedules(rawOn)`
  reads `on: { schedule: [{cron: ...}] }` (also tolerates the
  object/string degenerate forms). __test bundle exposed for unit
  tests. The existing `on: string[]` field is unchanged.

Scheduler:
- src/lib/scheduled-workflows.ts. Pipeline per tick:
    1. SELECT non-disabled workflows where parsed JSON contains
       "schedules" (cheap LIKE pre-filter).
    2. For each, compute `since` from the latest schedule-triggered
       run, falling back to (now - 6 min) for new workflows so they
       don't back-fire.
    3. firstCronToFire(schedules, since, now) picks the first cron
       that fired; we enqueue exactly one run per workflow per tick.
    4. enqueueRun(...) with event="schedule", ref=defaultBranch,
       commitSha=resolved-default-branch HEAD. Existing runner
       (src/lib/workflow-runner.ts) picks it up just like a manual
       run.
  Hard cap: MAX_RUNS_PER_TICK=50 so a misconfigured cron + empty
  workflow_runs cannot stampede the queue. Every step swallows DB
  errors and increments a result.errors counter — never throws into
  the autopilot loop.

Autopilot:
- New default task "scheduled-workflows" calls runScheduledWorkflowsTick()
  every tick. AUTOPILOT_DISABLED=1 still opts out the entire loop;
  no other tasks are touched.

Tests: +47 (30 cron parser/matcher, 17 scheduler pure helpers +
parser extension + tick fail-open). Total suite 1130 pass / 8 skip /
0 fail (was 1083 / 8 / 0).

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: a76d984
6 files changed+9151665c8bfa04b430b0c8e76a9c45d48912653f2940
6 changed files+915−1
Addedsrc/__tests__/cron.test.ts+244−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/cron.ts.
3 *
4 * Pure module — no DB, no clock side effects. We exhaustively cover:
5 * - Field parser edge cases (literals, ranges, steps, lists, wildcards)
6 * - Whole-expression parser (5-field shape, errors)
7 * - cronMatches per-minute logic
8 * - cronFiredBetween interval semantics
9 * - POSIX OR semantics for dom & dow
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 parseCron,
15 cronMatches,
16 cronFiredBetween,
17 __test,
18} from "../lib/cron";
19
20const at = (iso: string) => new Date(iso);
21
22describe("parseField", () => {
23 const f = __test.parseField;
24
25 it("expands * to the full range", () => {
26 expect(f("*", 0, 4)).toEqual([0, 1, 2, 3, 4]);
27 });
28
29 it("parses literals", () => {
30 expect(f("3", 0, 59)).toEqual([3]);
31 });
32
33 it("parses ranges", () => {
34 expect(f("2-5", 0, 23)).toEqual([2, 3, 4, 5]);
35 });
36
37 it("parses steps with wildcard base", () => {
38 expect(f("*/15", 0, 59)).toEqual([0, 15, 30, 45]);
39 });
40
41 it("parses steps with range base", () => {
42 expect(f("0-10/2", 0, 59)).toEqual([0, 2, 4, 6, 8, 10]);
43 });
44
45 it("parses comma-separated lists with mixed forms", () => {
46 expect(f("0,15,30-32,*/30", 0, 59)).toEqual([0, 15, 30, 31, 32]);
47 });
48
49 it("rejects out-of-range values", () => {
50 expect(f("60", 0, 59)).toBeNull();
51 expect(f("-1", 0, 59)).toBeNull();
52 });
53
54 it("rejects bogus syntax", () => {
55 expect(f("a", 0, 59)).toBeNull();
56 expect(f("1-", 0, 59)).toBeNull();
57 expect(f("/5", 0, 59)).toBeNull();
58 expect(f("1/0", 0, 59)).toBeNull();
59 });
60
61 it("rejects literal+step combinations (1/5 makes no sense)", () => {
62 expect(f("5/2", 0, 59)).toBeNull();
63 });
64
65 it("rejects descending ranges", () => {
66 expect(f("5-3", 0, 59)).toBeNull();
67 });
68});
69
70describe("parseCron — error paths", () => {
71 it("rejects empty input", () => {
72 expect(parseCron("")).toEqual({ ok: false, error: "empty cron expression" });
73 });
74
75 it("rejects @-aliases", () => {
76 const r = parseCron("@hourly");
77 expect(r.ok).toBe(false);
78 if (!r.ok) expect(r.error).toContain("not supported");
79 });
80
81 it("rejects unsupported chars (L W # ?)", () => {
82 const r = parseCron("0 0 L * *");
83 expect(r.ok).toBe(false);
84 if (!r.ok) expect(r.error).toContain("unsupported characters");
85 });
86
87 it("rejects wrong number of fields", () => {
88 expect(parseCron("0 0 *").ok).toBe(false);
89 expect(parseCron("0 0 * * * *").ok).toBe(false);
90 });
91
92 it("collapses whitespace before counting fields", () => {
93 expect(parseCron("0 * * * *").ok).toBe(true);
94 });
95});
96
97describe("parseCron — happy path", () => {
98 it("every minute → all minutes/hours/dows/etc full", () => {
99 const r = parseCron("* * * * *");
100 expect(r.ok).toBe(true);
101 if (!r.ok) return;
102 expect(r.cron.minute.length).toBe(60);
103 expect(r.cron.hour.length).toBe(24);
104 expect(r.cron.dom.length).toBe(31);
105 expect(r.cron.month.length).toBe(12);
106 expect(r.cron.dow.length).toBe(7);
107 });
108
109 it("normalises dow=7 to dow=0", () => {
110 const r = parseCron("0 0 * * 7");
111 expect(r.ok).toBe(true);
112 if (!r.ok) return;
113 expect(r.cron.dow).toEqual([0]);
114 });
115
116 it("dedupes (e.g. 0,0,0)", () => {
117 const r = parseCron("0,0,0 * * * *");
118 expect(r.ok).toBe(true);
119 if (!r.ok) return;
120 expect(r.cron.minute).toEqual([0]);
121 });
122});
123
124describe("cronMatches", () => {
125 it("matches every-minute cron at any timestamp", () => {
126 const r = parseCron("* * * * *");
127 if (!r.ok) throw new Error("setup");
128 expect(cronMatches(r.cron, at("2026-04-30T12:34:56Z"))).toBe(true);
129 });
130
131 it("matches a specific minute only at that minute", () => {
132 const r = parseCron("0 * * * *");
133 if (!r.ok) throw new Error("setup");
134 expect(cronMatches(r.cron, at("2026-04-30T12:00:00Z"))).toBe(true);
135 expect(cronMatches(r.cron, at("2026-04-30T12:01:00Z"))).toBe(false);
136 });
137
138 it("matches a specific hour:minute (daily)", () => {
139 const r = parseCron("30 9 * * *");
140 if (!r.ok) throw new Error("setup");
141 expect(cronMatches(r.cron, at("2026-04-30T09:30:00Z"))).toBe(true);
142 expect(cronMatches(r.cron, at("2026-04-30T09:00:00Z"))).toBe(false);
143 expect(cronMatches(r.cron, at("2026-04-30T10:30:00Z"))).toBe(false);
144 });
145
146 it("matches by day-of-week (Mondays at 09:00)", () => {
147 const r = parseCron("0 9 * * 1");
148 if (!r.ok) throw new Error("setup");
149 // 2026-04-27 was a Monday. (Verified via UTC.)
150 expect(cronMatches(r.cron, at("2026-04-27T09:00:00Z"))).toBe(true);
151 // 2026-04-28 Tuesday
152 expect(cronMatches(r.cron, at("2026-04-28T09:00:00Z"))).toBe(false);
153 });
154
155 it("uses POSIX OR for dom + dow when both are restricted", () => {
156 // "Run on the 1st of the month OR every Friday at 12:00"
157 const r = parseCron("0 12 1 * 5");
158 if (!r.ok) throw new Error("setup");
159 // 2026-04-01 was a Wednesday (1st of April) → dom matches → fire.
160 expect(cronMatches(r.cron, at("2026-04-01T12:00:00Z"))).toBe(true);
161 // 2026-04-03 was a Friday → dow matches → fire.
162 expect(cronMatches(r.cron, at("2026-04-03T12:00:00Z"))).toBe(true);
163 // 2026-04-04 Saturday, not 1st → no fire.
164 expect(cronMatches(r.cron, at("2026-04-04T12:00:00Z"))).toBe(false);
165 });
166
167 it("matches every 15 minutes (*/15)", () => {
168 const r = parseCron("*/15 * * * *");
169 if (!r.ok) throw new Error("setup");
170 for (const min of [0, 15, 30, 45]) {
171 expect(
172 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
173 ).toBe(true);
174 }
175 for (const min of [1, 16, 31, 46]) {
176 expect(
177 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
178 ).toBe(false);
179 }
180 });
181});
182
183describe("cronFiredBetween", () => {
184 it("returns true when at least one minute in (since, until] matches", () => {
185 const r = parseCron("0 * * * *");
186 if (!r.ok) throw new Error("setup");
187 // since 12:30, until 13:05 — 13:00 lies in (since, until], should fire.
188 const fired = cronFiredBetween(
189 r.cron,
190 at("2026-04-30T12:30:00Z"),
191 at("2026-04-30T13:05:00Z")
192 );
193 expect(fired).toBe(true);
194 });
195
196 it("returns false when no matching minute in the interval", () => {
197 const r = parseCron("0 * * * *");
198 if (!r.ok) throw new Error("setup");
199 const fired = cronFiredBetween(
200 r.cron,
201 at("2026-04-30T12:01:00Z"),
202 at("2026-04-30T12:30:00Z")
203 );
204 expect(fired).toBe(false);
205 });
206
207 it("excludes the `since` boundary (half-open)", () => {
208 const r = parseCron("0 * * * *");
209 if (!r.ok) throw new Error("setup");
210 // since exactly at 12:00 — must not fire that same minute, only the
211 // next 12:00 would (which is an hour later). Until = 12:30 → false.
212 expect(
213 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
214 ).toBe(false);
215 });
216
217 it("includes the `until` boundary (half-open at the right)", () => {
218 const r = parseCron("30 * * * *");
219 if (!r.ok) throw new Error("setup");
220 expect(
221 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
222 ).toBe(true);
223 });
224
225 it("returns false on a zero/negative interval", () => {
226 const r = parseCron("* * * * *");
227 if (!r.ok) throw new Error("setup");
228 expect(
229 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T11:00:00Z"))
230 ).toBe(false);
231 });
232
233 it("caps the lookback at 1 day so a misconfigured `since` cannot blow up", () => {
234 const r = parseCron("* * * * *");
235 if (!r.ok) throw new Error("setup");
236 // since in 2020, until now — should still return true (every-minute
237 // cron always fires) without iterating millions of minutes.
238 const start = Date.now();
239 expect(
240 cronFiredBetween(r.cron, at("2020-01-01T00:00:00Z"), new Date())
241 ).toBe(true);
242 expect(Date.now() - start).toBeLessThan(1000);
243 });
244});
Addedsrc/__tests__/scheduled-workflows.test.ts+126−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/scheduled-workflows.ts (pure helpers + the
3 * parser-side schedule extractor in workflow-parser.ts).
4 *
5 * The DB-touching pipeline (runScheduledWorkflowsTick) is exercised
6 * indirectly by the autopilot smoke test — here we focus on the
7 * pure decisions that drive it.
8 */
9
10import { describe, it, expect } from "bun:test";
11import {
12 schedulesFromParsedJson,
13 firstCronToFire,
14 runScheduledWorkflowsTick,
15 MAX_RUNS_PER_TICK,
16} from "../lib/scheduled-workflows";
17import { __test as parserTest } from "../lib/workflow-parser";
18
19describe("schedulesFromParsedJson", () => {
20 it("returns [] for empty / malformed input", () => {
21 expect(schedulesFromParsedJson("")).toEqual([]);
22 expect(schedulesFromParsedJson("{}")).toEqual([]);
23 expect(schedulesFromParsedJson("not json")).toEqual([]);
24 });
25
26 it("returns [] when schedules is missing or wrong type", () => {
27 expect(schedulesFromParsedJson('{"on":["push"]}')).toEqual([]);
28 expect(schedulesFromParsedJson('{"schedules":"0 * * * *"}')).toEqual([]);
29 expect(schedulesFromParsedJson('{"schedules":42}')).toEqual([]);
30 });
31
32 it("extracts a non-empty schedules array", () => {
33 const json = '{"schedules":["0 * * * *","30 9 * * 1"]}';
34 expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "30 9 * * 1"]);
35 });
36
37 it("filters out non-string and empty entries", () => {
38 const json = '{"schedules":["0 * * * *","",42,null,"15 14 * * *"]}';
39 expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "15 14 * * *"]);
40 });
41});
42
43describe("workflow-parser extractSchedules", () => {
44 const ex = parserTest.extractSchedules;
45
46 it("returns [] for non-mapping triggers", () => {
47 expect(ex("push")).toEqual([]);
48 expect(ex(["push"])).toEqual([]);
49 expect(ex(null)).toEqual([]);
50 expect(ex(undefined)).toEqual([]);
51 });
52
53 it("returns [] when the mapping has no schedule key", () => {
54 expect(ex({ push: { branches: ["main"] } })).toEqual([]);
55 });
56
57 it("extracts a single schedule entry (object form)", () => {
58 expect(ex({ schedule: { cron: "0 * * * *" } })).toEqual(["0 * * * *"]);
59 });
60
61 it("extracts an array of schedule entries", () => {
62 expect(
63 ex({ schedule: [{ cron: "0 * * * *" }, { cron: "30 12 * * 5" }] })
64 ).toEqual(["0 * * * *", "30 12 * * 5"]);
65 });
66
67 it("tolerates a bare string in schedule", () => {
68 expect(ex({ schedule: "0 * * * *" })).toEqual(["0 * * * *"]);
69 expect(ex({ schedule: ["0 * * * *", "30 9 * * 1"] })).toEqual([
70 "0 * * * *",
71 "30 9 * * 1",
72 ]);
73 });
74
75 it("ignores entries that are missing or non-string cron", () => {
76 expect(ex({ schedule: [{ cron: "" }, { cron: 42 }, {}] })).toEqual([]);
77 });
78});
79
80describe("firstCronToFire", () => {
81 const since = new Date("2026-04-30T12:00:00Z");
82 const until = new Date("2026-04-30T13:05:00Z");
83
84 it("returns null when schedules is empty", () => {
85 expect(firstCronToFire([], since, until)).toBeNull();
86 });
87
88 it("returns the first cron that fires in the window", () => {
89 // 0 13 * * * fires at 13:00 — in (12:00, 13:05]
90 expect(firstCronToFire(["0 13 * * *"], since, until)).toBe("0 13 * * *");
91 });
92
93 it("returns null when no cron in the list fires", () => {
94 // 0 23 fires at 23:00; not in our 12:00–13:05 window
95 expect(firstCronToFire(["0 23 * * *"], since, until)).toBeNull();
96 });
97
98 it("skips invalid cron strings without crashing", () => {
99 expect(
100 firstCronToFire(["@hourly", "0 13 * * *"], since, until)
101 ).toBe("0 13 * * *");
102 });
103
104 it("returns the first matching cron, not all of them", () => {
105 expect(
106 firstCronToFire(["0 13 * * *", "30 13 * * *"], since, until)
107 ).toBe("0 13 * * *");
108 });
109});
110
111describe("runScheduledWorkflowsTick — fail-open", () => {
112 it("returns a result object with the expected keys, never throws", async () => {
113 const r = await runScheduledWorkflowsTick(new Date("2026-04-30T13:00:00Z"));
114 expect(typeof r.considered).toBe("number");
115 expect(typeof r.fired).toBe("number");
116 expect(typeof r.errors).toBe("number");
117 expect(r.considered).toBeGreaterThanOrEqual(0);
118 expect(r.fired).toBeGreaterThanOrEqual(0);
119 expect(r.errors).toBeGreaterThanOrEqual(0);
120 });
121
122 it("MAX_RUNS_PER_TICK is a safe positive integer", () => {
123 expect(MAX_RUNS_PER_TICK).toBeGreaterThan(0);
124 expect(MAX_RUNS_PER_TICK).toBeLessThanOrEqual(1000);
125 });
126});
Modifiedsrc/lib/autopilot.ts+7−0View fileUnifiedSplit
1717import { sendDigestsToAll } from "./email-digest";
1818import { scanRepositoryForAlerts } from "./advisories";
1919import { releaseExpiredWaitTimers } from "./environments";
20import { runScheduledWorkflowsTick } from "./scheduled-workflows";
2021
2122export interface AutopilotTaskResult {
2223 name: string;
8687 await releaseExpiredWaitTimers();
8788 },
8889 },
90 {
91 name: "scheduled-workflows",
92 run: async () => {
93 await runScheduledWorkflowsTick();
94 },
95 },
8996 ];
9097}
9198
Addedsrc/lib/cron.ts+227−0View fileUnifiedSplit
1/**
2 * Tiny cron-expression parser + matcher.
3 *
4 * Standard 5-field UNIX cron:
5 *
6 * minute hour day-of-month month day-of-week
7 * 0-59 0-23 1-31 1-12 0-6 (Sun=0; Sat=6; 7 also accepted as Sun)
8 *
9 * Supports: literal numbers, `*`, ranges (`a-b`), step (`*\/n` or `a-b/n`),
10 * and comma-lists (`1,3,5`). No named months / weekdays, no `@hourly`,
11 * no `L`/`W`/`#` — those raise a parse error so callers can surface a
12 * useful message rather than silently never firing.
13 *
14 * Day-of-month and day-of-week interact via OR (POSIX semantics): if both
15 * are restricted, the schedule fires when EITHER matches. If one is `*`
16 * we conjoin with the other (the practical common case).
17 *
18 * Pure module — no DB, no clock side effects. Callers pass a Date.
19 */
20
21export type CronField = number[]; // sorted, deduped
22
23export type ParsedCron = {
24 minute: CronField;
25 hour: CronField;
26 dom: CronField;
27 month: CronField;
28 dow: CronField;
29 /** Original expression after trimming + collapsing whitespace. */
30 raw: string;
31};
32
33export type CronParseResult =
34 | { ok: true; cron: ParsedCron }
35 | { ok: false; error: string };
36
37const FIELD_BOUNDS: Record<string, [number, number]> = {
38 minute: [0, 59],
39 hour: [0, 23],
40 dom: [1, 31],
41 month: [1, 12],
42 dow: [0, 7], // 7 normalised to 0 below
43};
44
45/**
46 * Parse one field of a cron expression against [lo, hi]. Returns a sorted,
47 * de-duplicated list of valid integers, or `null` if the field is
48 * malformed. Supports `*`, `a`, `a-b`, `*\/n`, `a-b/n`, `a,b,c`.
49 */
50function parseField(
51 raw: string,
52 lo: number,
53 hi: number
54): CronField | null {
55 const out = new Set<number>();
56 const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
57 if (parts.length === 0) return null;
58
59 for (const part of parts) {
60 let baseLo = lo;
61 let baseHi = hi;
62 let step = 1;
63 let body = part;
64
65 const slash = body.indexOf("/");
66 if (slash >= 0) {
67 const stepStr = body.slice(slash + 1);
68 const stepN = Number.parseInt(stepStr, 10);
69 if (!Number.isInteger(stepN) || stepN <= 0) return null;
70 step = stepN;
71 const before = body.slice(0, slash);
72 // Empty before-slash (e.g. "/5") is a syntax error — must be either
73 // "*" or a literal range. Don't silently default to "*".
74 if (before === "") return null;
75 body = before;
76 }
77
78 if (body === "*") {
79 // baseLo / baseHi already span the full field.
80 } else if (body.includes("-")) {
81 const [aS, bS] = body.split("-");
82 const a = Number.parseInt(aS, 10);
83 const b = Number.parseInt(bS, 10);
84 if (!Number.isInteger(a) || !Number.isInteger(b)) return null;
85 if (a < lo || b > hi || a > b) return null;
86 baseLo = a;
87 baseHi = b;
88 } else {
89 const n = Number.parseInt(body, 10);
90 if (!Number.isInteger(n) || n < lo || n > hi) return null;
91 if (step !== 1) return null; // n/step makes no sense without a range
92 out.add(n);
93 continue;
94 }
95
96 for (let v = baseLo; v <= baseHi; v += step) {
97 out.add(v);
98 }
99 }
100
101 return [...out].sort((a, b) => a - b);
102}
103
104/**
105 * Parse a 5-field cron expression. Returns `{ok:true, cron}` or
106 * `{ok:false, error}`. Whitespace-tolerant; rejects unsupported syntax.
107 */
108export function parseCron(expr: string): CronParseResult {
109 const raw = (expr || "").replace(/\s+/g, " ").trim();
110 if (!raw) return { ok: false, error: "empty cron expression" };
111
112 // Fail fast on syntax we don't yet support so the user gets a clear
113 // error instead of a schedule that silently never fires.
114 if (/^@/.test(raw)) {
115 return {
116 ok: false,
117 error: `cron alias '${raw.split(" ")[0]}' is not supported (use 5-field expression)`,
118 };
119 }
120 if (/[LW#?]/i.test(raw)) {
121 return {
122 ok: false,
123 error: "cron contains unsupported characters (L, W, #, ?)",
124 };
125 }
126
127 const fields = raw.split(" ");
128 if (fields.length !== 5) {
129 return {
130 ok: false,
131 error: `cron must have 5 space-separated fields, got ${fields.length}`,
132 };
133 }
134
135 const [mField, hField, domField, monField, dowField] = fields;
136
137 const minute = parseField(mField, ...FIELD_BOUNDS.minute);
138 const hour = parseField(hField, ...FIELD_BOUNDS.hour);
139 const dom = parseField(domField, ...FIELD_BOUNDS.dom);
140 const month = parseField(monField, ...FIELD_BOUNDS.month);
141 const dowRaw = parseField(dowField, ...FIELD_BOUNDS.dow);
142
143 if (!minute) return { ok: false, error: `invalid minute field: ${mField}` };
144 if (!hour) return { ok: false, error: `invalid hour field: ${hField}` };
145 if (!dom) return { ok: false, error: `invalid day-of-month field: ${domField}` };
146 if (!month) return { ok: false, error: `invalid month field: ${monField}` };
147 if (!dowRaw) return { ok: false, error: `invalid day-of-week field: ${dowField}` };
148
149 // Normalise dow so 7 → 0 (both mean Sunday).
150 const dow = [...new Set(dowRaw.map((d) => (d === 7 ? 0 : d)))].sort(
151 (a, b) => a - b
152 );
153
154 return {
155 ok: true,
156 cron: {
157 raw,
158 minute,
159 hour,
160 dom,
161 month,
162 dow,
163 },
164 };
165}
166
167/**
168 * Does the cron fire on this exact minute (UTC)? `date` is rounded down
169 * to the start of its minute internally so callers can pass any Date.
170 */
171export function cronMatches(cron: ParsedCron, date: Date): boolean {
172 if (!cron) return false;
173 const m = date.getUTCMinutes();
174 const h = date.getUTCHours();
175 const d = date.getUTCDate();
176 const mo = date.getUTCMonth() + 1; // JS is 0-indexed
177 const dw = date.getUTCDay(); // 0..6, Sun=0
178
179 if (!cron.minute.includes(m)) return false;
180 if (!cron.hour.includes(h)) return false;
181 if (!cron.month.includes(mo)) return false;
182
183 // POSIX OR semantics for dom & dow: if either is restricted (not full
184 // wildcard), match if EITHER matches. If both unrestricted, both pass.
185 const domRestricted = cron.dom.length !== 31;
186 const dowRestricted = cron.dow.length !== 7;
187 const domMatch = cron.dom.includes(d);
188 const dowMatch = cron.dow.includes(dw);
189
190 if (!domRestricted && !dowRestricted) return true;
191 if (domRestricted && dowRestricted) return domMatch || dowMatch;
192 if (domRestricted) return domMatch;
193 return dowMatch;
194}
195
196/**
197 * Did the cron fire at any minute in the half-open interval (since, until]?
198 * Useful for "did this schedule trip in the last tick?" checks where
199 * `since` is the prior tick wall and `until` is the current wall. Caps
200 * the loop at 1 day so a misconfigured `since` from 2010 can't blow up.
201 */
202export function cronFiredBetween(
203 cron: ParsedCron,
204 since: Date,
205 until: Date
206): boolean {
207 if (!cron) return false;
208 const startMs = Math.floor(since.getTime() / 60000) * 60000 + 60000;
209 const endMs = Math.floor(until.getTime() / 60000) * 60000;
210 if (endMs < startMs) return false;
211
212 const ONE_DAY_MIN = 24 * 60;
213 const minuteCount = (endMs - startMs) / 60000 + 1;
214 const cap = Math.min(minuteCount, ONE_DAY_MIN);
215
216 for (let i = 0; i < cap; i++) {
217 const t = new Date(startMs + i * 60000);
218 if (cronMatches(cron, t)) return true;
219 }
220 return false;
221}
222
223/**
224 * Test-only export of the field parser so unit tests can pin parsing
225 * edge cases without going through parseCron's plumbing.
226 */
227export const __test = { parseField };
Addedsrc/lib/scheduled-workflows.ts+257−0View fileUnifiedSplit
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
32import { and, desc, eq, isNull, sql } from "drizzle-orm";
33import { db } from "../db";
34import {
35 workflowRuns,
36 workflows,
37 repositories,
38} from "../db/schema";
39import { parseCron, cronFiredBetween } from "./cron";
40import { enqueueRun } from "./workflow-runner";
41import { resolveRef, getDefaultBranch } from "../git/repository";
42
43export const MAX_RUNS_PER_TICK = 50;
44const SINCE_FALLBACK_MS = 6 * 60_000; // 6 min — slightly > default 5-min tick
45
46export type ScheduledTickResult = {
47 considered: number;
48 fired: number;
49 errors: number;
50};
51
52type 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 */
62export 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 */
78export 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
91async 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
112async 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 */
150export 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
179 for (const w of candidates) {
180 if (result.fired >= MAX_RUNS_PER_TICK) break;
181 result.considered += 1;
182
183 const schedules = schedulesFromParsedJson(w.parsed);
184 if (schedules.length === 0) continue;
185
186 const lastQ = await lastScheduleRunQueuedAt(w.id);
187 const since = lastQ
188 ? lastQ
189 : new Date(now.getTime() - SINCE_FALLBACK_MS);
190
191 const expr = firstCronToFire(schedules, since, now);
192 if (!expr) continue;
193
194 const repoMeta = await loadOwnerAndRepoName(w.repositoryId);
195 if (!repoMeta) {
196 result.errors += 1;
197 continue;
198 }
199
200 let commitSha: string | null = null;
201 try {
202 commitSha = await resolveRef(
203 repoMeta.ownerName,
204 repoMeta.repoName,
205 repoMeta.defaultBranch
206 );
207 } catch {
208 commitSha = null;
209 }
210 if (!commitSha) {
211 // Try to recover the default branch via the on-disk repo if the DB
212 // value is stale — best-effort. If still unknown, skip.
213 try {
214 const def = await getDefaultBranch(
215 repoMeta.ownerName,
216 repoMeta.repoName
217 );
218 if (def) {
219 commitSha = await resolveRef(
220 repoMeta.ownerName,
221 repoMeta.repoName,
222 def
223 );
224 }
225 } catch {
226 commitSha = null;
227 }
228 }
229 if (!commitSha) {
230 result.errors += 1;
231 continue;
232 }
233
234 try {
235 await enqueueRun({
236 workflowId: w.id,
237 repositoryId: w.repositoryId,
238 event: "schedule",
239 ref: `refs/heads/${repoMeta.defaultBranch}`,
240 commitSha,
241 triggeredBy: null,
242 });
243 result.fired += 1;
244 } catch {
245 result.errors += 1;
246 }
247 }
248
249 return result;
250}
251
252/** Test-only exposed internals so DB-less test cases can pin behaviour. */
253export const __test = {
254 schedulesFromParsedJson,
255 firstCronToFire,
256 SINCE_FALLBACK_MS,
257};
Modifiedsrc/lib/workflow-parser.ts+54−1View fileUnifiedSplit
4040export type ParsedWorkflow = {
4141 name: string;
4242 on: string[];
43 /**
44 * Cron expressions captured from `on: { schedule: [{cron: "..."}, ...] }`.
45 * Empty when the workflow has no schedule trigger (the common case).
46 * Strings are passed through verbatim — validation happens later when
47 * the scheduler tries to parse them via `src/lib/cron.ts`.
48 */
49 schedules?: string[];
4350 jobs: WorkflowJob[];
4451};
4552
497504 return null;
498505}
499506
507/**
508 * Extract cron expressions from the raw `on:` value when it is a mapping
509 * containing a `schedule:` key. Returns [] for any other shape so callers
510 * can unconditionally read `parsed.schedules ?? []`. Tolerant of:
511 * - schedule: [{cron: "0 * * * *"}, ...]
512 * - schedule: {cron: "0 * * * *"}
513 * - schedule: "0 * * * *" (legacy, not standard but seen in the wild)
514 *
515 * Pure helper — exported alongside the existing `__test` bundle.
516 */
517function extractSchedules(rawOn: unknown): string[] {
518 if (!rawOn || typeof rawOn !== "object" || Array.isArray(rawOn)) return [];
519 const m = rawOn as Record<string, unknown>;
520 const node = m.schedule;
521 if (node == null) return [];
522
523 const out: string[] = [];
524 const collect = (entry: unknown) => {
525 if (typeof entry === "string") {
526 const s = entry.trim();
527 if (s) out.push(s);
528 return;
529 }
530 if (entry && typeof entry === "object" && !Array.isArray(entry)) {
531 const cron = (entry as Record<string, unknown>).cron;
532 const s = typeof cron === "string" ? cron.trim() : "";
533 if (s) out.push(s);
534 }
535 };
536
537 if (Array.isArray(node)) {
538 for (const e of node) collect(e);
539 } else {
540 collect(node);
541 }
542 return out;
543}
544
500545function normaliseStep(
501546 raw: unknown,
502547 jobName: string,
575620 if (!on || on.length === 0) {
576621 return { ok: false, error: "workflow missing 'on' trigger" };
577622 }
623 const schedules = extractSchedules(doc.on);
578624
579625 const jobsRaw = doc.jobs;
580626 if (
594640 jobs.push(res.job);
595641 }
596642
597 return { ok: true, workflow: { name, on, jobs } };
643 const workflow: ParsedWorkflow = { name, on, jobs };
644 if (schedules.length > 0) workflow.schedules = schedules;
645 return { ok: true, workflow };
598646}
647
648/**
649 * Test-only export of the schedule extractor. Pure helper, no DB.
650 */
651export const __test = { extractSchedules };
599652