OXYGENHUB

pipeline.kdl: on { push; tag; manual; schedule } for the pipeline and each job bf7bb1b80fa3

push and tag take globs (none: any), manual is Run pipeline, schedule
takes five-field cron lines (a small parser in flow-core::cron). A
job's own on wins over the pipeline's; without one a job runs on
every push and by hand, as before. Pipeline::on_of and On::matches
decide; the hub applies them. Spec and tests.

refs #24
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 21:47 UTC
Signature
✔ signed by Andy McCall
Hash
bf7bb1b80fa3236734453259866162ee5abdae1bde94e797672adcf73da9ebd2
Depends on
02b127d2c721 a083cc802892 a67b97e9a717 a72abeeedd78 e0918b887850 e576e08617dc fc10d9e80383
Depended on by
e87c1c138187 e20897216221 46fe2c815cc3 ea81796bb757 1abadd4a2fa6 f2609cf795d8
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
16 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/flow-core/src/cron.rs +92 −0
1 +
//! Five-field cron lines for `on { schedule "…" }` (oxygenflow#24):
2 +
//! minute, hour, day of month, month, day of week (0 or 7 is Sunday), each
3 +
//! `*`, a number, a range `a-b`, a list `a,b`, or any of those `/step`.
4 +
//! The hub checks each minute which lines are due.
5 +
6 +
/// A parsed line: the values each field allows.
7 +
#[derive(Clone, Debug, PartialEq, Eq)]
8 +
pub struct Cron {
9 +
    minute: Vec<bool>,
10 +
    hour: Vec<bool>,
11 +
    day: Vec<bool>,
12 +
    month: Vec<bool>,
13 +
    weekday: Vec<bool>,
14 +
    /// Both day fields restricted: either may match, as cron has it.
15 +
    either_day: bool,
16 +
}
17 +
18 +
fn field(text: &str, lo: u32, hi: u32) -> Option<(Vec<bool>, bool)> {
19 +
    let mut allowed = vec![false; hi as usize + 1];
20 +
    let star = text == "*";
21 +
    for part in text.split(',') {
22 +
        let (range, step) = match part.split_once('/') {
23 +
            Some((r, s)) => (r, s.parse::<u32>().ok().filter(|s| *s > 0)?),
24 +
            None => (part, 1),
25 +
        };
26 +
        let (a, b) = if range == "*" {
27 +
            (lo, hi)
28 +
        } else if let Some((a, b)) = range.split_once('-') {
29 +
            (a.parse().ok()?, b.parse().ok()?)
30 +
        } else {
31 +
            let a: u32 = range.parse().ok()?;
32 +
            // `5/15` is from 5 to the end, every 15.
33 +
            (a, if part.contains('/') { hi } else { a })
34 +
        };
35 +
        if a < lo || b > hi || a > b {
36 +
            return None;
37 +
        }
38 +
        let mut v = a;
39 +
        while v <= b {
40 +
            allowed[v as usize] = true;
41 +
            v += step;
42 +
        }
43 +
    }
44 +
    Some((allowed, star))
45 +
}
46 +
47 +
impl Cron {
48 +
    pub fn parse(line: &str) -> Option<Cron> {
49 +
        let f: Vec<&str> = line.split_whitespace().collect();
50 +
        let [m, h, d, mo, w] = f.as_slice() else { return None };
51 +
        let (minute, _) = field(m, 0, 59)?;
52 +
        let (hour, _) = field(h, 0, 23)?;
53 +
        let (day, day_star) = field(d, 1, 31)?;
54 +
        let (month, _) = field(mo, 1, 12)?;
55 +
        let (mut weekday, week_star) = field(w, 0, 7)?;
56 +
        if weekday[7] {
57 +
            weekday[0] = true;
58 +
        }
59 +
        Some(Cron { minute, hour, day, month, weekday, either_day: !day_star && !week_star })
60 +
    }
61 +
62 +
    /// Whether the line is due at this minute (UTC): `weekday` 0 is Sunday.
63 +
    pub fn matches(&self, minute: u32, hour: u32, day: u32, month: u32, weekday: u32) -> bool {
64 +
        let get = |v: &Vec<bool>, i: u32| v.get(i as usize).copied().unwrap_or(false);
65 +
        let (d, w) = (get(&self.day, day), get(&self.weekday, weekday % 7));
66 +
        let days = if self.either_day { d || w } else { d && w };
67 +
        get(&self.minute, minute) && get(&self.hour, hour) && get(&self.month, month) && days
68 +
    }
69 +
}
70 +
71 +
#[cfg(test)]
72 +
mod tests {
73 +
    use super::Cron;
74 +
75 +
    #[test]
76 +
    fn lines_parse_and_match_as_cron_does() {
77 +
        let nightly = Cron::parse("0 3 * * *").unwrap();
78 +
        assert!(nightly.matches(0, 3, 15, 6, 2));
79 +
        assert!(!nightly.matches(1, 3, 15, 6, 2));
80 +
        let quarter = Cron::parse("*/15 9-17 * * 1-5").unwrap();
81 +
        assert!(quarter.matches(45, 17, 2, 10, 5));
82 +
        assert!(!quarter.matches(50, 17, 2, 10, 5));
83 +
        assert!(!quarter.matches(0, 12, 3, 10, 6), "Saturday");
84 +
        let sunday = Cron::parse("30 4 * * 7").unwrap();
85 +
        assert!(sunday.matches(30, 4, 4, 10, 0));
86 +
        let either = Cron::parse("0 0 1 * 1").unwrap();
87 +
        assert!(either.matches(0, 0, 1, 10, 3) && either.matches(0, 0, 5, 10, 1), "the 1st or a Monday");
88 +
        for bad in ["", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "*/0 * * * *", "a * * * *", "5-1 * * * *"] {
89 +
            assert!(Cron::parse(bad).is_none(), "{bad:?}");
90 +
        }
91 +
    }
92 +
}
modified crates/flow-core/src/lib.rs +1 −0
…
5 unchanged lines
6 6
7 7
pub mod api;
8 8
pub mod canonical;
9 +
pub mod cron;
9 10
pub mod pipeline;
10 11
pub mod receipt;
11 12
…
6 unchanged lines
modified crates/flow-core/src/pipeline.rs +119 −2
…
15 unchanged lines
16 16
    pub sandboxes: BTreeMap<String, Sandbox>,
17 17
    /// By name.
18 18
    pub jobs: BTreeMap<String, Job>,
19 +
    /// When the pipeline's jobs run, unless a job says (oxygenflow#24);
20 +
    /// absent: on every push and from "Run pipeline".
21 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
22 +
    pub on: Option<On>,
23 +
}
24 +
25 +
/// What starts a job (`on { push "main"; tag "v*"; manual; schedule "0 3 * * *" }`).
26 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27 +
pub struct On {
28 +
    /// Bookmark globs a push runs it for; `Some(vec![])`: any bookmark.
29 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
30 +
    pub push: Option<Vec<String>>,
31 +
    /// Tag globs a pushed tag runs it for; `Some(vec![])`: any tag.
32 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
33 +
    pub tag: Option<Vec<String>>,
34 +
    /// From "Run pipeline" (and `oh run start`).
35 +
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36 +
    pub manual: bool,
37 +
    /// Five-field cron lines, on the hub's clock, for the default bookmark.
38 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 +
    pub schedule: Vec<String>,
19 40
}
20 41
42 +
/// Why a run started, for [`On::matches`].
43 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44 +
pub enum Event<'a> {
45 +
    /// A push moved this bookmark.
46 +
    Push(&'a str),
47 +
    /// This tag was pushed.
48 +
    Tag(&'a str),
49 +
    /// "Run pipeline", `oh run start`.
50 +
    Manual,
51 +
    /// A `schedule` line came due.
52 +
    Schedule,
53 +
}
54 +
55 +
impl On {
56 +
    /// What a job without any `on` runs for: every push, and by hand.
57 +
    pub fn default_on() -> On {
58 +
        On { push: Some(vec![]), tag: None, manual: true, schedule: vec![] }
59 +
    }
60 +
61 +
    pub fn matches(&self, event: Event<'_>) -> bool {
62 +
        let any = |globs: &Option<Vec<String>>, name: &str| match globs {
63 +
            None => false,
64 +
            Some(g) if g.is_empty() => true,
65 +
            Some(g) => g.iter().any(|p| globset::Glob::new(p).is_ok_and(|p| p.compile_matcher().is_match(name))),
66 +
        };
67 +
        match event {
68 +
            Event::Push(b) => any(&self.push, b),
69 +
            Event::Tag(t) => any(&self.tag, t),
70 +
            Event::Manual => self.manual,
71 +
            Event::Schedule => !self.schedule.is_empty(),
72 +
        }
73 +
    }
74 +
}
75 +
76 +
impl Pipeline {
77 +
    /// When `job` runs: its own `on`, else the pipeline's, else the default.
78 +
    pub fn on_of(&self, job: &Job) -> On {
79 +
        job.on.clone().or_else(|| self.on.clone()).unwrap_or_else(On::default_on)
80 +
    }
81 +
}
82 +
21 83
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22 84
#[serde(rename_all = "lowercase")]
23 85
pub enum Runtime {
…
80 unchanged lines
104 166
    /// sandbox's when absent, else the site's default (oxygenflow#23).
105 167
    #[serde(default, skip_serializing_if = "Option::is_none")]
106 168
    pub timeout_s: Option<u64>,
169 +
    /// When it runs, over the pipeline's `on` (oxygenflow#24).
170 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
171 +
    pub on: Option<On>,
107 172
}
108 173
109 174
impl Job {
…
258 unchanged lines
368 433
        let mut schema = None;
369 434
        let mut sandboxes = BTreeMap::new();
370 435
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
436 +
        let mut on = None;
371 437
        for node in doc.nodes() {
372 438
            match node.name().value() {
439 +
                "on" => {
440 +
                    if on.is_some() {
441 +
                        ctx.err(node.span(), "`on` is given twice");
442 +
                    }
443 +
                    on = Some(parse_on(&mut ctx, node));
444 +
                }
373 445
                "schema-version" => {
374 446
                    ctx.no_children(node);
375 447
                    match ctx.one_integer(node) {
…
21 unchanged lines
397 469
                    }
398 470
                }
399 471
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
400 -
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, sandbox or job")),
472 +
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, sandbox or job")),
401 473
            }
402 474
        }
403 475
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
…
25 unchanged lines
429 501
            ctx.problems.sort_by_key(|p| p.line);
430 502
            return Err(Problems(ctx.problems));
431 503
        }
432 -
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect() })
504 +
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect(), on })
433 505
    }
434 506
435 507
    /// Jobs in an order where each comes after what it depends on (ties by name).
…
191 unchanged lines
627 699
        return None;
628 700
    }
629 701
    Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env, runs_on, timeout_s })
702 +
}
703 +
704 +
/// An `on` block: `push` and `tag` with globs (none: any), `manual`,
705 +
/// `schedule` with five-field cron lines.
706 +
fn parse_on(ctx: &mut Ctx, node: &KdlNode) -> On {
707 +
    let mut on = On::default();
708 +
    if !node.entries().is_empty() {
709 +
        ctx.err(node.span(), "`on` takes a block: on { push \"main\"; tag \"v*\"; manual }");
710 +
    }
711 +
    let globs = |ctx: &mut Ctx, c: &KdlNode| -> Vec<String> {
712 +
        let g = ctx.strings(c);
713 +
        for p in &g {
714 +
            if let Err(e) = globset::Glob::new(p) {
715 +
                ctx.err(c.span(), format!("{p:?}: {e}"));
716 +
            }
717 +
        }
718 +
        g
719 +
    };
720 +
    for c in block(ctx, node) {
721 +
        ctx.no_children(c);
722 +
        match c.name().value() {
723 +
            "push" => on.push.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
724 +
            "tag" => on.tag.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
725 +
            "manual" => on.manual = true,
726 +
            "schedule" => {
727 +
                for line in ctx.strings(c) {
728 +
                    if crate::cron::Cron::parse(&line).is_some() {
729 +
                        on.schedule.push(line);
730 +
                    } else {
731 +
                        ctx.err(
732 +
                            c.span(),
733 +
                            format!("schedule {line:?}: expected five cron fields (minute hour day month weekday), as \"0 3 * * *\""),
734 +
                        );
735 +
                    }
736 +
                }
737 +
            }
738 +
            other => ctx.err(c.span(), format!("unknown `on` event `{other}`; expected push, tag, manual or schedule")),
739 +
        }
740 +
    }
741 +
    if on.push.is_none() && on.tag.is_none() && !on.manual && on.schedule.is_empty() {
742 +
        ctx.err(node.span(), "`on` names no event, so nothing would run it");
743 +
    }
744 +
    on
630 745
}
631 746
632 747
/// The longest a pipeline may ask a job to run.
…
213 unchanged lines
846 961
        outputs: vec![],
847 962
        runs_on: vec![],
848 963
        timeout_s: None,
964 +
        on: None,
849 965
    };
850 966
    let mut seen = BTreeSet::new();
851 967
    let mut outputs_at = None;
…
28 unchanged lines
880 996
                ctx.no_children(c);
881 997
                job.timeout_s = parse_timeout(ctx, c);
882 998
            }
999 +
            "on" => job.on = Some(parse_on(ctx, c)),
883 1000
            "env" => parse_env(ctx, c, &mut job.env),
884 1001
            "exec" => {
885 1002
                ctx.no_children(c);
…
59 unchanged lines
modified crates/flow-core/src/tests.rs +32 −0
…
412 unchanged lines
413 413
    let long = text.replace("timeout \"3h30m\"", "timeout \"25h\"");
414 414
    assert!(Pipeline::parse(&long).unwrap_err().to_string().contains("from 1m to 24h"));
415 415
}
416 +
417 +
/// oxygenflow#24: `on` says what starts a job, at pipeline or job level;
418 +
/// a job without one runs on every push and by hand, as before.
419 +
#[test]
420 +
fn on_says_what_starts_a_job() {
421 +
    use crate::pipeline::{Event, Pipeline};
422 +
    let root = "a".repeat(64);
423 +
    let text = format!(
424 +
        "schema-version 1\non {{\n    push \"main\" \"feature/*\"\n    manual\n}}\nsandbox \"s\" {{\n    runtime \"microvm\"\n    rootfs \"cas://blob/{root}\"\n    memory-limit \"1GiB\"\n    cpu-limit 1\n    network \"isolated\"\n}}\njob \"test\" {{\n    sandbox \"s\"\n    exec \"true\"\n}}\njob \"dist\" {{\n    sandbox \"s\"\n    on {{\n        tag \"v*\"\n        manual\n    }}\n    exec \"true\"\n}}\njob \"nightly\" {{\n    sandbox \"s\"\n    on {{\n        schedule \"0 3 * * *\"\n    }}\n    exec \"true\"\n}}\n"
425 +
    );
426 +
    let p = Pipeline::parse(&text).unwrap();
427 +
    let runs = |job: &str, e: Event| p.on_of(&p.jobs[job]).matches(e);
428 +
    assert!(runs("test", Event::Push("main")) && runs("test", Event::Push("feature/9-x")));
429 +
    assert!(!runs("test", Event::Push("develop")), "not a bookmark the pipeline names");
430 +
    assert!(!runs("test", Event::Tag("v1.0.0")) && runs("test", Event::Manual));
431 +
    assert!(runs("dist", Event::Tag("v0.3.1")) && !runs("dist", Event::Tag("nightly")));
432 +
    assert!(!runs("dist", Event::Push("main")) && runs("dist", Event::Manual));
433 +
    assert!(runs("nightly", Event::Schedule) && !runs("nightly", Event::Manual));
434 +
    // Without any `on`, as before.
435 +
    let plain = text.replace("on {\n    push \"main\" \"feature/*\"\n    manual\n}\n", "");
436 +
    let p = Pipeline::parse(&plain).unwrap();
437 +
    assert!(p.on_of(&p.jobs["test"]).matches(Event::Push("anything")) && p.on_of(&p.jobs["test"]).matches(Event::Manual));
438 +
    for (bad, why) in [
439 +
        ("on {\n}\n", "names no event"),
440 +
        ("on {\n    when \"x\"\n}\n", "unknown `on` event"),
441 +
        ("on {\n    schedule \"0 3 * *\"\n}\n", "five cron fields"),
442 +
    ] {
443 +
        let t = format!("{bad}{plain}");
444 +
        let e = Pipeline::parse(&t).unwrap_err().to_string();
445 +
        assert!(e.contains(why), "{bad:?}: {e}");
446 +
    }
447 +
}
modified crates/flow/src/outputs.rs +1 −0
…
76 unchanged lines
77 77
            outputs: vec![],
78 78
            runs_on: vec![],
79 79
            timeout_s: None,
80 +
            on: None,
80 81
        }
81 82
    }
82 83
…
24 unchanged lines
modified docs/spec.md +16 −0
…
160 unchanged lines
161 161
}
162 162
163 163
164 +
**What starts a job.** An `on` block, at the top of the pipeline or in a job (the job's wins), names the events that run it: `push` with bookmark globs (none: any bookmark), `tag` with tag globs (none: any tag), `manual` ("Run pipeline", `oh run start`) and `schedule` with five-field cron lines on the hub's clock (UTC), run on the default bookmark. Without `on`, a job runs on every push and by hand, as before. A run has only the jobs its event starts (and what they depend on); an event that starts no job starts no run. A rerun repeats its run's event.
165 +
166 +
```kdl
167 +
on {
168 +
    push "main" "feature/*"
169 +
    manual
170 +
}
171 +
job "dist" {
172 +
    on {
173 +
        tag "v*"
174 +
        manual
175 +
    }
176 +
    …
177 +
}
178 +
```
179 +
164 180
**Time limits.** A job may say how long it may run with `timeout "90m"` (hours, minutes and seconds, as `"2h"`, `"1h30m"` or `"45s"`, from a minute to a day); a sandbox's `timeout` is the default for its jobs. Without one, the hub's default applies, and the hub caps every job at the site's maximum (both set by an administrator on Admin → Runners). The runner stops a job at its limit, and the hub ends one its runner stopped reporting a few minutes after it. A timeout is part of the compiled job, so a job without one compiles as before.
165 181
166 182
**Runner labels.** A sandbox or a job may say `runs-on "LABEL" ...`: lower-case letters, digits, `-`, `_` and `.`, at most 64 bytes each, given on one or more lines. A job needs a runner that has every label its sandbox and it name. A runner reports its labels and architecture when it registers and on each claim (`flow runner register --label`). The hub hands a job only to a runner with all of them, within the runner's scope (OxygenHub spec 13.2), and a job no runner can take waits, saying so on the queue. Labels are part of the compiled job, so a job that asks for none compiles as before.
…
250 unchanged lines