OXYGENHUB

Runner updates in waves: a later wave takes a version once the waves before it proved it; an error stops it (refs andymccall/oxygenflow#84) 1ec29691b67a

Test groups get a wave (1 to 9). A version reaches wave n+1 once every runner of the earlier waves that took it has finished soak_jobs jobs on it (site setting, 5) with none in error, or soak_hours (24) have passed since its first job there; runners in no wave come last. A job ending in error on a wave stops the version: the next runner to ask holds it for all (a page view only says so), naming the job. Jobs keep the runner build that claimed them (migration 0102, flow_jobs.runner_version). Admin and organisation pages set waves and the soak; oh runner-updates shows them. First slice: no comparison with the version before, and no hub-driven rollback of a stopped wave.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d695efb67452
When
· 2026-10-28 01:21 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1ec29691b67afa5ab607846ce4bf5334d76401e26c7bb82a6bc0f277c3e36956
Depends on
166f6e9bf4f1 19bb31ff1f5c 3b1bc27fe805 42fa95540468 4dfa4599b3f7 501d8b268dd4 5c6e92fd8d71 8501fd5815b9 9bbca80f777a b5668108487f b6b7e4cf6b24 b6dfcb61cf5e d23464c0ac60 d7050703d2f4 dc5c64296b0a faf5246c900e
Depended on by
04f9c984439e c26267b3aba6 8306eca4e4a3 851a26a2a6d1
In bookmarks
main
In tags
v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
57 atoms, 2 tree operations, 15 files

Browse the tree at this change

added crates/hub/migrations-pg/0102_runner_waves.sql +6 −0
1 +
-- Runner updates in waves (oxygenflow#84): the runner build each job ran
2 +
-- on, from its claim, to count the jobs that prove a version; and the wave
3 +
-- a test group's runners take a version in (none: after every wave).
4 +
ALTER TABLE flow_jobs ADD COLUMN runner_version TEXT NOT NULL DEFAULT '';
5 +
ALTER TABLE runner_groups ADD COLUMN wave BIGINT;
6 +
CREATE INDEX flow_jobs_runner_version ON flow_jobs (runner_version) WHERE runner_version <> '';
added crates/hub/migrations/0102_runner_waves.sql +6 −0
1 +
-- Runner updates in waves (oxygenflow#84): the runner build each job ran
2 +
-- on, from its claim, to count the jobs that prove a version; and the wave
3 +
-- a test group's runners take a version in (none: after every wave).
4 +
ALTER TABLE flow_jobs ADD COLUMN runner_version TEXT NOT NULL DEFAULT '';
5 +
ALTER TABLE runner_groups ADD COLUMN wave INTEGER;
6 +
CREATE INDEX flow_jobs_runner_version ON flow_jobs (runner_version) WHERE runner_version <> '';
modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **Runner updates in waves (oxygenflow#84).** A test group can be given a wave (1 to 9) on Admin › Runners › Updates or an organisation's settings: a new version reaches wave 2's runners only once every runner of wave 1 that took it has finished a number of jobs on it (5 by default) with none ending in error, or a number of hours (24) has passed since its first job there; runners in no wave come last. A job that ends in `error` on a wave's runner (the runner's or the machine's fault; failed tests do not count) stops the version: the next runner to ask holds it for every runner, and the page names the job, until an administrator lifts the hold. Each job now keeps the runner build it was claimed by (migration 0102). A runner waiting says so: "flow 0.5.1 waits for wave 1 to prove it: 1 of 2 runners on it, 3 jobs". First slice: errors are not yet weighed against the version before, and a stopped wave's runners are not rolled back by the hub.
8 +
5 9
## 0.9.11 (2026-10-28)
6 10
7 11
<<<<<<< oxy conflict 1 of 1, side 1 of 2: change 681d2bd9 "Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5)"
…
395 unchanged lines
modified crates/hub-api/src/lib.rs +3 −0
…
1640 unchanged lines
1641 1641
    pub percent: Option<i64>,
1642 1642
    /// Its runners now, by name.
1643 1643
    pub members: Vec<String>,
1644 +
    /// The wave it takes a version in (oxygenflow#84); `None`: after every wave.
1645 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1646 +
    pub wave: Option<i64>,
1644 1647
}
1645 1648
1646 1649
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
27 unchanged lines
modified crates/hub/src/api/mod.rs +1 −0
…
2928 unchanged lines
2929 2929
                name: g.g.name.clone(),
2930 2930
                percent: g.g.percent,
2931 2931
                members: g.members.clone(),
2932 +
                wave: g.g.wave,
2932 2933
            })
2933 2934
            .collect(),
2934 2935
        runners: v
…
360 unchanged lines
modified crates/hub/src/db/flow.rs +7 −0
…
852 unchanged lines
853 853
    claim_ranked(pool, runtimes, runner, token_hash, room, scope, labels, &|_| false).await
854 854
}
855 855
856 +
/// Keep the runner build job `id` was claimed by (oxygenflow#84), for
857 +
/// counting the jobs that prove a version in a wave.
858 +
pub async fn set_runner_version(pool: &Pool, id: i64, version: &str) -> sqlx::Result<()> {
859 +
    sqlx::query("UPDATE flow_jobs SET runner_version = $1 WHERE id = $2").bind(version).bind(id).execute(pool).await?;
860 +
    Ok(())
861 +
}
862 +
856 863
/// [`claim_within`], passing over a job `elsewhere` says a better runner
857 864
/// should take (oxygenflow#42).
858 865
#[allow(clippy::too_many_arguments)]
…
806 unchanged lines
modified crates/hub/src/db/runner_updates.rs +51 −0
…
32 unchanged lines
33 33
    /// `None`: only the chosen ones.
34 34
    pub percent: Option<i64>,
35 35
    pub created_ms: i64,
36 +
    /// The wave its runners take a version in (oxygenflow#84): wave 1
37 +
    /// first, each later one once those before it have proved the version;
38 +
    /// `None`: after every wave.
39 +
    pub wave: Option<i64>,
36 40
}
37 41
38 42
pub async fn policies(pool: &Pool) -> sqlx::Result<Vec<Policy>> {
…
54 unchanged lines
93 97
    Ok(())
94 98
}
95 99
100 +
pub async fn set_wave(pool: &Pool, id: i64, wave: Option<i64>) -> sqlx::Result<()> {
101 +
    sqlx::query("UPDATE runner_groups SET wave = $1 WHERE id = $2").bind(wave).bind(id).execute(pool).await?;
102 +
    Ok(())
103 +
}
104 +
96 105
/// Remove a group, its members and its policy.
97 106
pub async fn delete_group(pool: &Pool, id: i64) -> sqlx::Result<()> {
98 107
    sqlx::query("DELETE FROM runner_group_members WHERE group_id = $1").bind(id).execute(pool).await?;
…
60 unchanged lines
159 168
/// Lift the hold on `version`; whether there was one.
160 169
pub async fn lift(pool: &Pool, version: &str) -> sqlx::Result<bool> {
161 170
    Ok(sqlx::query("DELETE FROM runner_update_holds WHERE version = $1").bind(version).execute(pool).await?.rows_affected() > 0)
171 +
}
172 +
173 +
/// How a runner has done on a version (oxygenflow#84): its finished jobs
174 +
/// on that build, how many ended in `error` (the runner's or the
175 +
/// infrastructure's fault, not a test's), and when the first started.
176 +
#[derive(Clone, Debug, Default, PartialEq, Eq, FromRow)]
177 +
pub struct Soak {
178 +
    pub runner_id: i64,
179 +
    pub jobs: i64,
180 +
    pub errors: i64,
181 +
    pub first_ms: Option<i64>,
182 +
}
183 +
184 +
/// Every runner's [`Soak`] on `version`.
185 +
pub async fn soaks(pool: &Pool, version: &str) -> sqlx::Result<Vec<Soak>> {
186 +
    sqlx::query_as(
187 +
        "SELECT runner_id, COUNT(*) AS jobs, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors, MIN(started_ms) AS first_ms
188 +
         FROM flow_jobs WHERE runner_version = $1 AND runner_id IS NOT NULL AND status IN ('passed', 'failed', 'error')
189 +
         GROUP BY runner_id",
190 +
    )
191 +
    .bind(version)
192 +
    .fetch_all(pool)
193 +
    .await
194 +
}
195 +
196 +
/// The first job on `version` that ended in error on runner `runner`, in
197 +
/// words: `job build of OWNER/NAME run #N: why`.
198 +
pub async fn first_error(pool: &Pool, version: &str, runner: i64) -> sqlx::Result<Option<String>> {
199 +
    let row: Option<(String, String, String, i64, String)> = sqlx::query_as(
200 +
        "SELECT j.name, p.owner, p.name, r.number, j.error FROM flow_jobs j
201 +
         JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
202 +
         WHERE j.runner_version = $1 AND j.runner_id = $2 AND j.status = 'error'
203 +
         ORDER BY j.finished_ms LIMIT 1",
204 +
    )
205 +
    .bind(version)
206 +
    .bind(runner)
207 +
    .fetch_optional(pool)
208 +
    .await?;
209 +
    Ok(row.map(|(job, owner, name, n, error)| {
210 +
        let why: String = error.chars().take(120).collect();
211 +
        format!("job {job} of {owner}/{name} run #{n}{}", if why.is_empty() { String::new() } else { format!(": {why}") })
212 +
    }))
162 213
}
modified crates/hub/src/flow.rs +7 −4
…
1124 unchanged lines
1125 1125
    else {
1126 1126
        return Ok(StatusCode::NO_CONTENT.into_response());
1127 1127
    };
1128 +
    // The build it runs on, for waves of runner updates (oxygenflow#84).
1129 +
    fdb::set_runner_version(&app.db, job.id, &crate::runner_updates::version_of(&me_now.version)).await?;
1128 1130
    // Why here (oxygenflow#42).
1129 1131
    let waited =
1130 1132
        HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&(hub_key.clone(), job.id))).map(|t| now - t);
…
387 unchanged lines
1518 1520
pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> {
1519 1521
    let me = runner_auth(&app, &headers).await?;
1520 1522
    db::runners::seen(&app.db, me.id, None, None).await?;
1521 -
    let policy = crate::runner_updates::Levels::load(&app.db).await?.resolve(&me);
1523 +
    let levels = crate::runner_updates::Levels::load(&app.db).await?;
1524 +
    let policy = levels.resolve(&me);
1522 1525
    let hour = time::OffsetDateTime::now_utc().hour();
1523 -
    let (offer, note) = crate::runner_updates::offer(&app.db, &me, &policy, hour).await?;
1526 +
    let (offer, note) = crate::runner_updates::offer(&app.db, &levels, &me, &policy, hour, db::now_ms(), true).await?;
1524 1527
    Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note }))
1525 1528
}
1526 1529
…
15 unchanged lines
1542 1545
    // A build that rolled back here is held back from every runner (#326).
1543 1546
    if let Some(v) = crate::runner_updates::rolled_back_version(note) {
1544 1547
        let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back");
1545 -
        db::runner_updates::hold(&app.db, &v, &me.name, why).await?;
1546 -
        db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok();
1548 +
        db::runner_updates::hold(&app.db, &v, &me.name, &format!("it rolled back on {} ({why})", me.name)).await?;
1549 +
        db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("it rolled back on {}: {why}", me.name)).await.ok();
1547 1550
    }
1548 1551
    db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok();
1549 1552
    Ok(StatusCode::NO_CONTENT)
…
1586 unchanged lines
modified crates/hub/src/runner_updates.rs +184 −9
…
2 unchanged lines
3 3
//! owner, its test groups and the runner itself set, the nearest setting
4 4
//! winning. Off unless some level turns updates on.
5 5
6 -
use crate::db::runner_updates::{self as rdb, Group, Policy};
6 +
use crate::db::runner_updates::{self as rdb, Group, Policy, Soak};
7 7
use crate::db::runners::Runner;
8 8
use crate::db::{self, Pool};
9 9
use flow_core::api::{UpdateOffer, UpdatePolicy};
…
2 unchanged lines
12 12
/// The site setting naming the repository whose releases carry runner
13 13
/// builds (`owner/name`), each an asset `flow-OS-ARCH`.
14 14
pub const SETTING_REPO: &str = "runner_updates_repo";
15 +
/// The site settings for proving a version in a wave (oxygenflow#84): the
16 +
/// jobs each runner of the wave that took it must finish, or the hours
17 +
/// since its first job there after which one job is enough.
18 +
pub const SETTING_SOAK_JOBS: &str = "runner_updates_soak_jobs";
19 +
pub const SETTING_SOAK_HOURS: &str = "runner_updates_soak_hours";
20 +
pub const SOAK_JOBS: i64 = 5;
21 +
pub const SOAK_HOURS: i64 = 24;
15 22
16 23
/// A runner's place, 0 to 99, for percentage groups: a stable hash of its
17 24
/// id, so a group's share is the same machines every rollout, and a
…
41 unchanged lines
59 66
        self.policies.get(&(scope.to_string(), key.to_string()))
60 67
    }
61 68
69 +
    /// The wave `r` takes a version in: the first of its groups' waves;
70 +
    /// `None` after every wave.
71 +
    pub fn wave_of(&self, r: &Runner) -> Option<i64> {
72 +
        self.groups_of(r).iter().filter_map(|g| g.wave).min()
73 +
    }
74 +
75 +
    /// The waves before `r`'s, in order, each with its runners among `all`:
76 +
    /// the waves of the groups that may hold `r` (the site's, and its
77 +
    /// owner's), a runner counted in its own first wave.
78 +
    pub fn waves_before<'a>(&self, r: &Runner, all: &'a [Runner]) -> Vec<(i64, Vec<&'a Runner>)> {
79 +
        let mine = self.wave_of(r);
80 +
        let mut waves: Vec<i64> =
81 +
            self.groups.iter().filter(|g| in_reach(g, r)).filter_map(|g| g.wave).filter(|w| mine.is_none_or(|m| *w < m)).collect();
82 +
        waves.sort();
83 +
        waves.dedup();
84 +
        waves
85 +
            .into_iter()
86 +
            .map(|w| {
87 +
                let members = all
88 +
                    .iter()
89 +
                    .filter(|o| self.wave_of(o) == Some(w) && self.groups_of(o).iter().any(|g| g.wave == Some(w) && in_reach(g, r)))
90 +
                    .collect();
91 +
                (w, members)
92 +
            })
93 +
            .collect()
94 +
    }
95 +
62 96
    /// The levels that apply to `r`, nearest first, each with its name.
63 97
    fn chain(&self, r: &Runner) -> Vec<(String, &Policy)> {
64 98
        let mut out = vec![];
…
86 unchanged lines
151 185
/// `flow-windows-x86_64.exe`.
152 186
pub fn asset_for(os: &str, arch: &str) -> String {
153 187
    format!("flow-{os}-{arch}{}", if os == "windows" { ".exe" } else { "" })
188 +
}
189 +
190 +
/// Where a wave stands with a version (oxygenflow#84).
191 +
#[derive(Clone, Debug, PartialEq, Eq)]
192 +
pub enum WaveState {
193 +
    /// Every runner of the wave that took it has finished its soak jobs on
194 +
    /// it, or the soak time has passed since its first job there; or the
195 +
    /// wave has no runners.
196 +
    Proved,
197 +
    /// Not yet: `on` of `runners` run it, with `jobs` jobs between them.
198 +
    Soaking { runners: usize, on: usize, jobs: i64 },
199 +
    /// A job on it ended in error on this runner: the version stops here.
200 +
    Stopped { runner_id: i64 },
201 +
}
202 +
203 +
/// Where a wave of `members` stands with a version, from each runner's
204 +
/// [`Soak`] on it, at `now` (ms).
205 +
pub fn wave_state(members: &[i64], soaks: &[Soak], soak_jobs: i64, soak_ms: i64, now: i64) -> WaveState {
206 +
    let mine: Vec<&Soak> = soaks.iter().filter(|s| members.contains(&s.runner_id) && s.jobs > 0).collect();
207 +
    if let Some(s) = mine.iter().find(|s| s.errors > 0) {
208 +
        return WaveState::Stopped { runner_id: s.runner_id };
209 +
    }
210 +
    if members.is_empty() {
211 +
        return WaveState::Proved;
212 +
    }
213 +
    let jobs = mine.iter().map(|s| s.jobs).sum();
214 +
    let soaked = mine.iter().filter_map(|s| s.first_ms).min().is_some_and(|first| now - first >= soak_ms);
215 +
    if !mine.is_empty() && (soaked || mine.iter().all(|s| s.jobs >= soak_jobs)) {
216 +
        WaveState::Proved
217 +
    } else {
218 +
        WaveState::Soaking { runners: members.len(), on: mine.len(), jobs }
219 +
    }
220 +
}
221 +
222 +
/// What the waves before `r`'s say about `version`: `Ok(None)` go ahead;
223 +
/// `Ok(Some(why))` wait; `Err((runner, why))` it stopped on that runner.
224 +
async fn wave_gate(
225 +
    pool: &Pool,
226 +
    levels: &Levels,
227 +
    r: &Runner,
228 +
    version: &str,
229 +
    now: i64,
230 +
) -> sqlx::Result<Result<Option<String>, (String, String)>> {
231 +
    let all = db::runners::list(pool).await?;
232 +
    let waves = levels.waves_before(r, &all);
233 +
    if waves.is_empty() {
234 +
        return Ok(Ok(None));
235 +
    }
236 +
    let setting = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d);
237 +
    let soak_jobs = setting(db::accounts::setting(pool, SETTING_SOAK_JOBS).await?, SOAK_JOBS);
238 +
    let soak_hours = setting(db::accounts::setting(pool, SETTING_SOAK_HOURS).await?, SOAK_HOURS);
239 +
    let soaks = rdb::soaks(pool, version).await?;
240 +
    for (w, members) in waves {
241 +
        let ids: Vec<i64> = members.iter().map(|m| m.id).collect();
242 +
        match wave_state(&ids, &soaks, soak_jobs, soak_hours * 3_600_000, now) {
243 +
            WaveState::Proved => continue,
244 +
            WaveState::Stopped { runner_id } => {
245 +
                let name = members.iter().find(|m| m.id == runner_id).map(|m| m.name.clone()).unwrap_or_default();
246 +
                let job = rdb::first_error(pool, version, runner_id).await?.unwrap_or_else(|| "a job".into());
247 +
                return Ok(Err((name.clone(), format!("wave {w} stopped it on {name}: {job} ended in error"))));
248 +
            }
249 +
            WaveState::Soaking { runners, on, jobs } => {
250 +
                return Ok(Ok(Some(format!(
251 +
                    "flow {version} waits for wave {w} to prove it: {on} of {runners} runner{} on it, {jobs} job{} (each needs {soak_jobs} without an error, or {soak_hours} h)",
252 +
                    if runners == 1 { "" } else { "s" },
253 +
                    if jobs == 1 { "" } else { "s" },
254 +
                ))));
255 +
            }
256 +
        }
257 +
    }
258 +
    Ok(Ok(None))
154 259
}
155 260
156 261
/// The build `r` should install under `policy` at hour `hour` (UTC), or
157 -
/// why there is none.
158 -
pub async fn offer(pool: &Pool, r: &Runner, policy: &UpdatePolicy, hour: u8) -> sqlx::Result<(Option<UpdateOffer>, String)> {
262 +
/// why there is none. A version a wave stopped is held back from every
263 +
/// runner when `act` (a runner asking; a page only says so).
264 +
pub async fn offer(
265 +
    pool: &Pool,
266 +
    levels: &Levels,
267 +
    r: &Runner,
268 +
    policy: &UpdatePolicy,
269 +
    hour: u8,
270 +
    now: i64,
271 +
    act: bool,
272 +
) -> sqlx::Result<(Option<UpdateOffer>, String)> {
159 273
    if !policy.updates {
160 274
        return Ok((None, format!("updates are off ({})", policy.from[0])));
161 275
    }
…
20 unchanged lines
182 296
        return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel)));
183 297
    }
184 298
    if let Some(h) = rdb::held(pool, &want).await? {
185 -
        return Ok((
186 -
            None,
187 -
            format!("flow {want} is held: it rolled back on {} ({}); lift the hold on Admin › Runners › Updates", h.runner, h.why),
188 -
        ));
299 +
        return Ok((None, format!("flow {want} is held: {}; lift the hold on Admin › Runners › Updates", h.why)));
189 300
    }
190 301
    let going_back = older(&want, &have);
191 302
    if going_back && matches!(policy.channel.as_str(), "stable" | "canary") {
192 303
        return Ok((None, format!("runs flow {have}, newer than the {} channel's {want}", policy.channel)));
304 +
    }
305 +
    // Waves (oxygenflow#84): a later wave takes a version once those before it proved it.
306 +
    if !going_back {
307 +
        match wave_gate(pool, levels, r, &want, now).await? {
308 +
            Ok(None) => {}
309 +
            Ok(Some(why)) => return Ok((None, why)),
310 +
            Err((runner, why)) => {
311 +
                if act {
312 +
                    rdb::hold(pool, &want, &runner, &why).await?;
313 +
                    db::accounts::audit(pool, None, "runner.update.held", &want, &why).await.ok();
314 +
                }
315 +
                return Ok((None, format!("flow {want} is held: {why}; lift the hold on Admin › Runners › Updates")));
316 +
            }
317 +
        }
193 318
    }
194 319
    let file = asset_for(&r.os, &r.arch);
195 320
    let assets = db::releases::assets(pool, release.id).await?;
…
56 unchanged lines
252 377
            pol("group", "1", &|p| p.channel = Some("canary".into())),
253 378
            pol("runner", "3", &|p| p.updates = Some(false)),
254 379
        ]);
255 -
        l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0 });
380 +
        l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0, wave: None });
256 381
        l.chosen.insert((1, 2));
257 382
        l.chosen.insert((1, 9));
258 383
…
16 unchanged lines
275 400
    #[test]
276 401
    fn a_percentage_group_takes_its_share_of_the_runners_in_reach() {
277 402
        let mut l = Levels::default();
278 -
        l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0 });
403 +
        l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0, wave: None });
279 404
        let n = (1..=500).filter(|id| !l.groups_of(&runner(*id, "")).is_empty()).count();
280 405
        assert!((60..=140).contains(&n), "{n}");
406 +
    }
407 +
408 +
    #[test]
409 +
    fn a_wave_proves_a_version_by_its_jobs_or_its_time_and_an_error_stops_it() {
410 +
        let soak = |runner_id, jobs, errors, first_ms| Soak { runner_id, jobs, errors, first_ms: Some(first_ms) };
411 +
        let hour = 3_600_000;
412 +
        // Nobody has taken it yet: not proved.
413 +
        assert_eq!(wave_state(&[1, 2], &[], 5, 24 * hour, 0), WaveState::Soaking { runners: 2, on: 0, jobs: 0 });
414 +
        // One runner took it and ran 3 of its 5; the other has not updated.
415 +
        let s = [soak(1, 3, 0, 0), soak(9, 50, 0, 0)];
416 +
        assert_eq!(wave_state(&[1, 2], &s, 5, 24 * hour, hour), WaveState::Soaking { runners: 2, on: 1, jobs: 3 });
417 +
        // Five each on every runner that took it proves it.
418 +
        assert_eq!(wave_state(&[1, 2], &[soak(1, 5, 0, 0)], 5, 24 * hour, hour), WaveState::Proved);
419 +
        // Or a day since the first job there, with no error.
420 +
        assert_eq!(wave_state(&[1, 2], &[soak(1, 1, 0, 0)], 5, 24 * hour, 24 * hour), WaveState::Proved);
421 +
        // An error anywhere in the wave stops it, however many passed.
422 +
        assert_eq!(
423 +
            wave_state(&[1, 2], &[soak(1, 40, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, 48 * hour),
424 +
            WaveState::Stopped { runner_id: 2 }
425 +
        );
426 +
        // Another wave's errors are not this one's.
427 +
        assert_eq!(wave_state(&[1], &[soak(1, 5, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, hour), WaveState::Proved);
428 +
        // An empty wave holds nothing up.
429 +
        assert_eq!(wave_state(&[], &[], 5, 24 * hour, 0), WaveState::Proved);
430 +
    }
431 +
432 +
    #[test]
433 +
    fn a_runner_waits_for_the_waves_before_its_own() {
434 +
        let mut l = Levels::default();
435 +
        let group = |id, name: &str, wave| Group { id, owner: String::new(), name: name.into(), percent: None, created_ms: 0, wave };
436 +
        l.groups.extend([group(1, "first", Some(1)), group(2, "second", Some(2)), group(3, "watchers", None)]);
437 +
        l.chosen.extend([(1, 1), (2, 2), (2, 1), (3, 3)]);
438 +
        let all: Vec<Runner> = (1..=4).map(|id| runner(id, "")).collect();
439 +
        let ids = |ws: Vec<(i64, Vec<&Runner>)>| {
440 +
            ws.into_iter().map(|(w, rs)| (w, rs.iter().map(|r| r.id).collect::<Vec<_>>())).collect::<Vec<_>>()
441 +
        };
442 +
        assert_eq!(l.wave_of(&all[0]), Some(1), "its first wave");
443 +
        assert!(l.waves_before(&all[0], &all).is_empty(), "wave 1 goes first");
444 +
        assert_eq!(ids(l.waves_before(&all[1], &all)), vec![(1, vec![1])]);
445 +
        assert_eq!(l.wave_of(&all[2]), None, "a group with no wave");
446 +
        assert_eq!(ids(l.waves_before(&all[2], &all)), vec![(1, vec![1]), (2, vec![2])], "in no wave: after every wave");
447 +
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])]);
448 +
        // An owner's waves gate only that owner's runners.
449 +
        l.groups.push(Group { id: 4, owner: "acme".into(), name: "acme first".into(), percent: None, created_ms: 0, wave: Some(1) });
450 +
        l.chosen.insert((4, 5));
451 +
        let mut all = all;
452 +
        all.push(runner(5, "acme"));
453 +
        all.push(runner(6, "acme"));
454 +
        assert_eq!(ids(l.waves_before(&all[5], &all)), vec![(1, vec![1, 5]), (2, vec![2])]);
455 +
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])], "not another owner's");
281 456
    }
282 457
283 458
    #[test]
…
20 unchanged lines
modified crates/hub/src/web/runner_updates.rs +68 −4
…
125 unchanged lines
126 126
    runners: Vec<RunnerView>,
127 127
    /// Builds held back after rolling back on a runner (#326).
128 128
    holds: Vec<rdb::Hold>,
129 +
    /// Proving a version in a wave (oxygenflow#84).
130 +
    soak_jobs: i64,
131 +
    soak_hours: i64,
129 132
    notice: Option<String>,
130 133
    error: Option<String>,
131 134
}
…
65 unchanged lines
197 200
    let mut out = vec![];
198 201
    for r in all.into_iter().filter(|r| reach.holds_runner(r)) {
199 202
        let effective = levels.resolve(&r);
200 -
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?;
203 +
        let (_, offer) = ru::offer(&app.db, &levels, &r, &effective, hour, crate::db::now_ms(), false).await?;
201 204
        out.push(RunnerView {
202 205
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
203 206
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
…
10 unchanged lines
214 217
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
215 218
    let v = views(app, &Reach::Site).await.map_err(e)?;
216 219
    let holds = rdb::holds(&app.db).await.map_err(e)?;
217 -
    Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, holds, notice, error }))
220 +
    let number = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d);
221 +
    let soak_jobs = number(accounts::setting(&app.db, ru::SETTING_SOAK_JOBS).await.map_err(e)?, ru::SOAK_JOBS);
222 +
    let soak_hours = number(accounts::setting(&app.db, ru::SETTING_SOAK_HOURS).await.map_err(e)?, ru::SOAK_HOURS);
223 +
    Ok(Page(UpdatesPage {
224 +
        shell,
225 +
        repo,
226 +
        site: v.site,
227 +
        owners: v.owners,
228 +
        groups: v.groups,
229 +
        runners: v.runners,
230 +
        holds,
231 +
        soak_jobs,
232 +
        soak_hours,
233 +
        notice,
234 +
        error,
235 +
    }))
218 236
}
219 237
220 238
/// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong.
…
18 unchanged lines
239 257
    #[serde(rename = "_csrf")]
240 258
    csrf_token: String,
241 259
    repo: String,
260 +
    #[serde(default)]
261 +
    soak_jobs: String,
262 +
    #[serde(default)]
263 +
    soak_hours: String,
242 264
}
243 265
244 266
/// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds.
…
11 unchanged lines
256 278
            return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await;
257 279
        }
258 280
    }
281 +
    // Waves (oxygenflow#84): blank keeps the default.
282 +
    for (key, value, max, what) in [
283 +
        (ru::SETTING_SOAK_JOBS, &f.soak_jobs, 1000, "jobs each runner of a wave finishes"),
284 +
        (ru::SETTING_SOAK_HOURS, &f.soak_hours, 720, "hours a wave soaks"),
285 +
    ] {
286 +
        let v = value.trim();
287 +
        if !v.is_empty() && !v.parse::<i64>().is_ok_and(|n| (1..=max).contains(&n)) {
288 +
            return page(&app, shell, None, Some(format!("The {what} is a whole number from 1 to {max}."))).await;
289 +
        }
290 +
        accounts::set_setting(&app.db, key, v).await.map_err(|e| Error::from_db(shell.clone(), e))?;
291 +
    }
259 292
    accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?;
260 293
    accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok();
261 294
    let n = if repo.is_empty() {
…
122 unchanged lines
384 417
    percent: String,
385 418
    #[serde(default)]
386 419
    runner: Vec<i64>,
420 +
    /// Its wave (oxygenflow#84), 1 to 9; blank for none.
421 +
    #[serde(default)]
422 +
    wave: String,
423 +
}
424 +
425 +
/// The wave a form gives, or why it cannot be one.
426 +
fn wave_of(f: &GroupForm) -> Result<Option<i64>, String> {
427 +
    let w = f.wave.trim();
428 +
    if w.is_empty() {
429 +
        return Ok(None);
430 +
    }
431 +
    match w.parse::<i64>() {
432 +
        Ok(n) if (1..=9).contains(&n) => Ok(Some(n)),
433 +
        _ => Err("A wave is a whole number from 1 to 9, or blank for after every wave.".into()),
434 +
    }
387 435
}
388 436
389 437
/// The percentage a form gives, or why it cannot be one.
…
36 unchanged lines
426 474
    if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) {
427 475
        return Ok(Err(format!("There is already a test group {name:?} for those runners.")));
428 476
    }
477 +
    let wave = match wave_of(f) {
478 +
        Ok(w) => w,
479 +
        Err(why) => return Ok(Err(why)),
480 +
    };
429 481
    let chosen = chosen_in_reach(app, &owner, f).await?;
430 482
    let g = rdb::create_group(&app.db, &owner, &name, percent).await?;
483 +
    rdb::set_wave(&app.db, g.id, wave).await?;
431 484
    if percent.is_none() {
432 485
        rdb::set_members(&app.db, g.id, &chosen).await?;
433 486
    }
434 -
    let what = match percent {
487 +
    let mut what = match percent {
435 488
        Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }),
436 489
        None => format!("{} chosen runners", chosen.len()),
437 490
    };
491 +
    if let Some(w) = wave {
492 +
        what.push_str(&format!(", wave {w}"));
493 +
    }
438 494
    accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
439 495
    Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below.")))
440 496
}
…
20 unchanged lines
461 517
        Ok(p) => p,
462 518
        Err(why) => return Ok(Err(why)),
463 519
    };
520 +
    let wave = match wave_of(f) {
521 +
        Ok(w) => w,
522 +
        Err(why) => return Ok(Err(why)),
523 +
    };
464 524
    let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? };
465 525
    rdb::set_percent(&app.db, id, percent).await?;
466 526
    rdb::set_members(&app.db, id, &chosen).await?;
467 -
    let what = match percent {
527 +
    rdb::set_wave(&app.db, id, wave).await?;
528 +
    let mut what = match percent {
468 529
        Some(p) => format!("{p}% of its runners"),
469 530
        None => format!("{} chosen runners", chosen.len()),
470 531
    };
532 +
    if let Some(w) = wave {
533 +
        what.push_str(&format!(" in wave {w}"));
534 +
    }
471 535
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok();
472 536
    Ok(Ok(format!("Test group {} is now {what}.", g.name)))
473 537
}
…
64 unchanged lines
modified crates/hub/tests/flow.rs +131 −0
…
1427 unchanged lines
1428 1428
    let (st, body) = claim_as(credential.clone()).await;
1429 1429
    assert_eq!(st, StatusCode::OK, "{body}");
1430 1430
    let job: serde_json::Value = serde_json::from_str(&body).unwrap();
1431 +
    // The build it ran on is kept with the job, for waves of updates (oxygenflow#84).
1432 +
    let kept: String =
1433 +
        sqlx::query_scalar("SELECT runner_version FROM flow_jobs WHERE runner_id = $1").bind(plex.id).fetch_one(&h.app.db).await.unwrap();
1434 +
    assert_eq!(kept, "0.1.0");
1431 1435
    let (_, page) = web("GET", format!("/admin/runners/{}", plex.id), None).await;
1432 1436
    assert!(
1433 1437
        page.contains("andy/demo") && page.contains(job["job"]["name"].as_str().unwrap()) && page.contains("Running now</dt><dd>1 job"),
…
5583 unchanged lines
7017 7021
    assert!(!ask(0).await.policy.updates, "back to the site's off");
7018 7022
}
7019 7023
7024 +
/// oxygenflow#84: a version reaches a later wave once every runner of the
7025 +
/// waves before it that took it has run its soak jobs on it; a job ending
7026 +
/// in error there stops it for every runner.
7027 +
#[tokio::test(flavor = "multi_thread")]
7028 +
async fn a_version_reaches_wave_two_once_wave_one_proves_it() {
7029 +
    use oxy_core::identity::SecretKey;
7030 +
    let h = hub().await;
7031 +
    create(&h.app, "andy", "oxygenflow").await;
7032 +
    let res = oxygenhub::router(h.app.clone())
7033 +
        .oneshot(
7034 +
            Request::post("/register")
7035 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
7036 +
                .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup"))
7037 +
                .unwrap(),
7038 +
        )
7039 +
        .await
7040 +
        .unwrap();
7041 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
7042 +
    let admin = db::accounts::account_by_name(&h.app.db, "carol").await.unwrap().unwrap();
7043 +
    db::accounts::set_admin(&h.app.db, admin.id, true).await.unwrap();
7044 +
    let web = |method: &'static str, path: &str, body: Option<String>| {
7045 +
        let (app, cookie, path) = (h.app.clone(), cookie.clone(), path.to_string());
7046 +
        async move {
7047 +
            let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie);
7048 +
            if body.is_some() {
7049 +
                req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
7050 +
            }
7051 +
            let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap();
7052 +
            (res.status(), String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
7053 +
        }
7054 +
    };
7055 +
    let (_, page) = web("GET", "/admin/runners/updates", None).await;
7056 +
    let csrf = {
7057 +
        let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
7058 +
        page[i..].split('"').next().unwrap().to_string()
7059 +
    };
7060 +
    let mut creds = vec![];
7061 +
    for name in ["plex", "narvi"] {
7062 +
        let (_, page) = web("POST", "/admin/runners/token", Some(format!("_csrf={csrf}"))).await;
7063 +
        let i = page.find("--token ").unwrap() + "--token ".len();
7064 +
        let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect();
7065 +
        let identity = SecretKey::generate().identity(&format!("{name} (OxygenFlow runner)"), "", 1);
7066 +
        let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0 (build)", "max_jobs": 1 });
7067 +
        let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info });
7068 +
        let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
7069 +
        assert_eq!(st, StatusCode::OK, "{body}");
7070 +
        creds.push(serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string());
7071 +
    }
7072 +
    let ask = |who: usize| {
7073 +
        let (app, cred) = (h.app.clone(), creds[who].clone());
7074 +
        async move {
7075 +
            let (st, body) = call(&app, "GET", "/api/v1/flow/runner/update", Some(&cred), None).await;
7076 +
            assert_eq!(st, StatusCode::OK, "{body}");
7077 +
            serde_json::from_str::<flow_core::api::UpdateAnswer>(&body).unwrap()
7078 +
        }
7079 +
    };
7080 +
    let repo = db::repos::get(&h.app.db, "andy", "oxygenflow").await.unwrap().unwrap();
7081 +
    let rel = db::releases::create(&h.app.db, repo.id, "v0.2.0", "0.2.0", "", false, None).await.unwrap();
7082 +
    db::releases::add_asset(&h.app.db, rel.id, "flow-linux-x86_64", 42, &"ab".repeat(32), "application/octet-stream").await.unwrap();
7083 +
    let (_, page) =
7084 +
        web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow&soak_jobs=2&soak_hours=48"))).await;
7085 +
    assert!(page.contains("runner builds come from andy/oxygenflow") && page.contains("value=\"2\""), "{page}");
7086 +
    let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow&soak_jobs=0"))).await;
7087 +
    assert!(page.contains("is a whole number from 1 to 1000"), "{page}");
7088 +
    web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&updates=on"))).await;
7089 +
7090 +
    // plex in wave 1, narvi in wave 2.
7091 +
    let runners = db::runners::list(&h.app.db).await.unwrap();
7092 +
    let id = |name: &str| runners.iter().find(|r| r.name == name).unwrap().id;
7093 +
    let (_, page) =
7094 +
        web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=first&mode=chosen&runner={}&wave=1", id("plex"))))
7095 +
            .await;
7096 +
    assert!(page.contains("Test group first made: 1 chosen runners, wave 1") && page.contains(" · wave 1</small>"), "{page}");
7097 +
    let (_, page) =
7098 +
        web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=rest&mode=chosen&runner={}&wave=0", id("narvi"))))
7099 +
            .await;
7100 +
    assert!(page.contains("A wave is a whole number from 1 to 9"), "{page}");
7101 +
    web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=rest&mode=chosen&runner={}&wave=2", id("narvi")))).await;
7102 +
    assert_eq!(ask(0).await.offer.map(|o| o.version).as_deref(), Some("0.2.0"), "wave 1 goes first");
7103 +
    let b = ask(1).await;
7104 +
    assert!(b.offer.is_none() && b.note.contains("flow 0.2.0 waits for wave 1 to prove it: 0 of 1 runner on it, 0 jobs"), "{b:?}");
7105 +
7106 +
    // plex runs 0.2.0: one job is not enough, two are.
7107 +
    let run: i64 = sqlx::query_scalar(
7108 +
        "INSERT INTO flow_runs (repo_id, bookmark, state, heads, status, created_ms, number) VALUES ($1, 'main', 's', 'h', 'passed', 1, 1) RETURNING id",
7109 +
    )
7110 +
    .bind(repo.id)
7111 +
    .fetch_one(&h.app.db)
7112 +
    .await
7113 +
    .unwrap();
7114 +
    let job = |name: &'static str, status: &'static str| {
7115 +
        let (app, plex) = (h.app.clone(), id("plex"));
7116 +
        async move {
7117 +
            sqlx::query(
7118 +
                "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, runner, runner_id, started_ms, finished_ms, error, runner_version)
7119 +
                 VALUES ($1, $2, 0, '', '{}', 'microvm', 'microvm', $3, 'plex', $4, $5, $5, $6, '0.2.0')",
7120 +
            )
7121 +
            .bind(run)
7122 +
            .bind(name)
7123 +
            .bind(status)
7124 +
            .bind(plex)
7125 +
            .bind(db::now_ms())
7126 +
            .bind(if status == "error" { "the runner lost its disk" } else { "" })
7127 +
            .execute(&app.db)
7128 +
            .await
7129 +
            .unwrap();
7130 +
        }
7131 +
    };
7132 +
    job("build", "passed").await;
7133 +
    assert!(ask(1).await.note.contains("1 of 1 runner on it, 1 job (each needs 2"), "still soaking");
7134 +
    job("test", "failed").await;
7135 +
    assert_eq!(ask(1).await.offer.map(|o| o.version).as_deref(), Some("0.2.0"), "a failed test is not the runner's fault: proved");
7136 +
    let (_, page) = web("GET", "/admin/runners/updates", None).await;
7137 +
    assert!(!page.contains("waits for wave"), "{page}");
7138 +
7139 +
    // An error on wave 1 stops it: the page only says so; the next runner to ask holds it for all.
7140 +
    job("lint", "error").await;
7141 +
    let (_, page) = web("GET", "/admin/runners/updates", None).await;
7142 +
    assert!(page.contains("wave 1 stopped it on plex: job lint of andy/oxygenflow run #1: the runner lost its disk"), "{page}");
7143 +
    assert!(db::runner_updates::held(&h.app.db, "0.2.0").await.unwrap().is_none(), "a page view holds nothing");
7144 +
    let b = ask(1).await;
7145 +
    assert!(b.offer.is_none() && b.note.contains("flow 0.2.0 is held: wave 1 stopped it on plex"), "{b:?}");
7146 +
    assert!(ask(0).await.note.contains("is held"), "wave 1 too");
7147 +
    let (_, page) = web("GET", "/admin/runners/updates", None).await;
7148 +
    assert!(page.contains("Held builds") && page.contains("wave 1 stopped it on plex"), "{page}");
7149 +
}
7150 +
7020 7151
/// #323: a WASM job has no image, so its placement says nothing of one.
7021 7152
#[tokio::test(flavor = "multi_thread")]
7022 7153
async fn a_wasm_jobs_placement_names_no_image() {
…
120 unchanged lines
modified crates/oh/src/main.rs +12 −2
…
949 unchanged lines
950 950
            }
951 951
            for g in &all.groups {
952 952
                let of = if g.owner.is_empty() { "the site's runners".to_string() } else { format!("{}'s runners", g.owner) };
953 -
                let what = g.percent.map(|p| format!("{p}% of {of}")).unwrap_or_else(|| format!("chosen, of {of}"));
953 +
                let mut what = g.percent.map(|p| format!("{p}% of {of}")).unwrap_or_else(|| format!("chosen, of {of}"));
954 +
                if let Some(w) = g.wave {
955 +
                    what.push_str(&format!(", wave {w}"));
956 +
                }
954 957
                println!(
955 958
                    "  group {} ({what}): {}",
956 959
                    ui::strong(&g.name),
…
1300 unchanged lines
2257 2260
        let all = hub_api::RunnerUpdates {
2258 2261
            repo: String::new(),
2259 2262
            settings: vec![],
2260 -
            groups: vec![hub_api::RunnerGroup { id: 4, owner: "acme".into(), name: "canary".into(), percent: None, members: vec![] }],
2263 +
            groups: vec![hub_api::RunnerGroup {
2264 +
                id: 4,
2265 +
                owner: "acme".into(),
2266 +
                name: "canary".into(),
2267 +
                percent: None,
2268 +
                members: vec![],
2269 +
                wave: None,
2270 +
            }],
2261 2271
            runners: vec![],
2262 2272
            holds: vec![],
2263 2273
        };
…
50 unchanged lines
modified docs/oh.md +1 −1
…
64 unchanged lines
65 65
| `oh drop list [--user NAME]` | Your drops, or someone's public ones |
66 66
| `oh drop view ID [-f FILE]` | A drop's files, by id or address; `-f` prints one file's content alone |
67 67
| `oh drop delete ID` | Delete a drop and its history |
68 -
| `oh runner-updates [show]` | Site administrators: runners that update themselves (oxygenhub#322): where builds come from, every level's settings, test groups, each runner's policy and what it is offered, held builds |
68 +
| `oh runner-updates [show]` | Site administrators: runners that update themselves (oxygenhub#322): where builds come from, every level's settings, test groups and their waves (oxygenflow#84), each runner's policy and what it is offered (or which wave it waits for), held builds |
69 69
| `oh runner-updates set SCOPE [--updates on\|off\|inherit] [--channel C] [--window 02-05] [--dry-run on\|off\|inherit]` | Change one level (`site`, `owner:NAME`, `group:NAME`, `runner:NAME`); what is not given stays; `inherit` clears a setting |
70 70
| `oh runner-updates lift VERSION` | Offer a build held after it rolled back on a runner (#326) |
71 71
| `oh api PATH [-X METHOD] [-d JSON]` | Any API call, JSON out |
…
7 unchanged lines
modified templates/admin_runner_updates.html +11 −3
…
22 unchanged lines
23 23
  <label for="update-repo">The repository whose releases carry runner builds</label>
24 24
  <input type="text" id="update-repo" name="repo" value="{{ repo }}" placeholder="owner/name, e.g. andymccall/oxygenflow">
25 25
  <p class="small mute">Each release offers one file per machine, <code>flow-linux-x86_64</code>, <code>flow-linux-aarch64</code>, <code>flow-linux-riscv64</code>, <code>flow-windows-x86_64.exe</code>. <em>stable</em> is its newest release, <em>canary</em> its newest including pre-releases; a version (<code>0.3.4</code>) pins it, going back if need be.</p>
26 +
  <h3>Waves</h3>
27 +
  <p class="small mute">Give test groups a wave (1, 2, …) and a new version reaches them in order: wave 2's runners are offered it once every runner of wave 1 that took it has finished this many jobs on it with none ending in error, or this many hours have passed since its first job there. A job that ends in error (the runner's or the machine's fault, not a test's) on a wave stops the version for everyone until you lift it. Runners in no wave come last.</p>
28 +
  <div class="pol-row">
29 +
    <label class="pol">Jobs each <input type="number" name="soak_jobs" min="1" max="1000" size="4" value="{{ soak_jobs }}"></label>
30 +
    <label class="pol">or hours <input type="number" name="soak_hours" min="1" max="720" size="4" value="{{ soak_hours }}"></label>
31 +
  </div>
26 32
  <button type="submit">Save</button>
27 33
</form>
28 34
{% if !holds.is_empty() %}
29 35
<section class="panel strip warn" id="holds">
30 36
  <h2>Held builds</h2>
31 -
  <p class="small">A build that rolled back on a runner is offered to no other until you lift the hold.</p>
37 +
  <p class="small">A build that rolled back on a runner, or that a wave stopped, is offered to no other until you lift the hold.</p>
32 38
  {% for h in holds %}
33 39
  <form class="pol-form" method="post" action="/admin/runners/updates/holds/lift"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="version" value="{{ h.version }}">
34 -
    <strong>flow {{ h.version }}</strong><span class="small">rolled back on {{ h.runner }}: {{ h.why }} · <time title="{{ h.at_ms|when_i }}">{{ h.at_ms|ago_i }}</time></span><button type="submit" class="small">LIFT</button>
40 +
    <strong>flow {{ h.version }}</strong><span class="small">{{ h.why }} · <time title="{{ h.at_ms|when_i }}">{{ h.at_ms|ago_i }}</time></span><button type="submit" class="small">LIFT</button>
35 41
  </form>
36 42
  {% endfor %}
37 43
</section>
…
26 unchanged lines
64 70
  {% if groups.is_empty() %}<p class="mute small">No test groups yet.</p>{% endif %}
65 71
  {% for gv in groups %}{% let g = gv.g %}
66 72
  <div class="card wide update-group">
67 -
    <h3>{{ g.name }} <small class="mute">{{ gv.reach }} · {% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3>
73 +
    <h3>{{ g.name }} <small class="mute">{{ gv.reach }} · {% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now{% if let Some(w) = g.wave %} · wave {{ w }}{% endif %}</small></h3>
68 74
    <p class="small">{% if gv.members.is_empty() %}<span class="mute">No runners in it.</span>{% else %}{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}{% endif %}</p>
69 75
    <form class="pol-form" method="post" action="/admin/runners/updates/policy">
70 76
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}">
…
5 unchanged lines
76 82
      <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage of {{ gv.reach }}: <input type="number" name="percent" min="1" max="100" size="4" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label>
77 83
      <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label>
78 84
      <div class="pol-row">{% for rv in runners %}{% if g.owner.is_empty() || g.owner == rv.r.scope_owner %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endif %}{% endfor %}</div>
85 +
      <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" value="{% if let Some(w) = g.wave %}{{ w }}{% endif %}" title="Blank: after every wave"></label>
79 86
      <button type="submit">Save members</button>
80 87
    </form>
81 88
    <form class="tiny" method="post" action="/admin/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}? Its runners follow the levels above it."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form>
…
10 unchanged lines
92 99
    <label class="pol"><input type="radio" name="mode" value="percent"> a percentage: <input type="number" name="percent" min="1" max="100" size="4">%</label>
93 100
    <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label>
94 101
    <div class="pol-row">{% for rv in runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div>
102 +
    <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" title="Blank: after every wave"></label>
95 103
    <button type="submit">Make group</button>
96 104
  </form>
97 105
</section>
…
24 unchanged lines
modified templates/org_settings.html +3 −1
…
115 unchanged lines
116 116
  {% if updates.groups.is_empty() %}<p class="mute small">None yet.</p>{% endif %}
117 117
  {% for gv in updates.groups %}{% let g = gv.g %}
118 118
  <div class="card wide update-group">
119 -
    <h3>{{ g.name }} <small class="mute">{% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3>
119 +
    <h3>{{ g.name }} <small class="mute">{% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now{% if let Some(w) = g.wave %} · wave {{ w }}{% endif %}</small></h3>
120 120
    <p class="small">{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}</p>
121 121
    <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy">
122 122
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}">
…
5 unchanged lines
128 128
      <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage: <input type="number" name="percent" min="1" max="100" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label>
129 129
      <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label>
130 130
      <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endfor %}</div>
131 +
      <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" value="{% if let Some(w) = g.wave %}{{ w }}{% endif %}" title="Blank: after every wave"></label>
131 132
      <button type="submit">Save members</button>
132 133
    </form>
133 134
    <form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form>
…
7 unchanged lines
141 142
    <label class="pol"><input type="radio" name="mode" value="percent"> a percentage of the organisation's runners: <input type="number" name="percent" min="1" max="100">%</label>
142 143
    <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label>
143 144
    <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div>
145 +
    <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" title="Blank: after every wave"></label>
144 146
    <button type="submit">Make group</button>
145 147
  </form>
146 148
  {% if !updates.runners.is_empty() %}
…
20 unchanged lines