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 |