OXYGENHUB
# 8a3334b7a364 oxygenhub / crates / hub / src / flow.rs
43.5 KiB · Rust · last changed Scheduled runs: on { schedule } jobs start on the default bookmark when due 1 month ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35
    if heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
38
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
39
    let started = match bookmark.strip_prefix("tags/") {
40
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
41
        None => {
42
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
43
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
44
        }
45
    };
46
    if let Err(e) = started {
47
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
48
    }
49
}
50
51
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
52
/// moving it. Returns the new run's number.
53
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
54
    if !enabled(app, row) {
55
        return Err("OxygenFlow is not enabled for this repository".into());
56
    }
57
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
58
    let b = bookmark.to_string();
59
    let found = app
60
        .repos
61
        .read(&repo, move |r, _| {
62
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
63
            Ok(Some((st, r.store().get_state(&st)?.heads)))
64
        })
65
        .await
66
        .map_err(|e| e.to_string())?;
67
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
68
    if heads.is_empty() {
69
        return Err(format!("{bookmark} has no changes yet"));
70
    }
71
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
72
        .await?
73
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
74
}
75
76
/// "Run pipeline" on one committed change: the state whose only head is
77
/// `change`, whatever bookmarks hold it. Returns the new run's number.
78
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
79
    if !enabled(app, row) {
80
        return Err("OxygenFlow is not enabled for this repository".into());
81
    }
82
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
83
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
84
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
85
    if !known {
86
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
87
    }
88
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
89
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
90
        .await?
91
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
92
}
93
94
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
95
/// run whose checks replace the old ones. Returns the new run's number.
96
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
97
    if !enabled(app, row) {
98
        return Err("OxygenFlow is not enabled for this repository".into());
99
    }
100
    if run.live() {
101
        return Err(format!("run #{} is still {}", run.number, run.status));
102
    }
103
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
104
    if heads.is_empty() {
105
        return Err("the run names no changes".into());
106
    }
107
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
108
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
109
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
110
        .await?
111
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
112
}
113
114
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
115
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
116
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
117
    use flow_core::pipeline::Event;
118
    let t = match trigger.strip_prefix("rerun:") {
119
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
120
        None => trigger,
121
    };
122
    if let Some(tag) = t.strip_prefix("tag:") {
123
        Event::Tag(tag)
124
    } else if t == "push" {
125
        Event::Push(bookmark)
126
    } else if t == "schedule" {
127
        Event::Schedule
128
    } else {
129
        Event::Manual
130
    }
131
}
132
133
/// The event to repeat when rerunning `run`.
134
fn original_event(run: &fdb::Run) -> String {
135
    let t = run.trigger.as_str();
136
    match t.strip_prefix("rerun:") {
137
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
138
        None => t.to_string(),
139
    }
140
}
141
142
/// The paths the changes a push brought touch: the changes in `heads`'
143
/// state that the bookmark's previous run did not have, and the files their
144
/// edits and tree operations name, as the new state places them. `None`
145
/// (run every job) for the bookmark's first run, or when it cannot be told.
146
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
147
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
148
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
149
    if old.is_empty() {
150
        return None;
151
    }
152
    let repo = RepoName::new(&row.owner, &row.name)?;
153
    let new = heads.to_vec();
154
    app.repos
155
        .read(&repo, move |r, cache| {
156
            let fresh = r.negotiate(&old, &new)?;
157
            let loaded = cache.load(r, &new)?;
158
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
159
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
160
            let mut paths = std::collections::BTreeSet::new();
161
            for h in fresh {
162
                let (c, _) = r.store().get_change_lazy(&h)?;
163
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
164
                for n in nodes {
165
                    if let Some(p) = by_node.get(&n) {
166
                        paths.insert((*p).clone());
167
                    }
168
                }
169
            }
170
            Ok(paths.into_iter().collect::<Vec<String>>())
171
        })
172
        .await
173
        .ok()
174
}
175
176
/// Whether a job runs for a push that touched `touched`: always without a
177
/// scope or without knowing what was touched, else when a path matches.
178
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
179
    let Some(touched) = touched else { return true };
180
    if scope.is_empty() {
181
        return true;
182
    }
183
    let mut b = globset::GlobSetBuilder::new();
184
    for g in scope {
185
        match globset::Glob::new(g) {
186
            Ok(g) => {
187
                b.add(g);
188
            }
189
            Err(_) => return true,
190
        }
191
    }
192
    let Ok(set) = b.build() else { return true };
193
    touched.iter().any(|p| set.is_match(p))
194
}
195
196
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
197
/// left out of the run, unless a job that runs depends on them.
198
#[allow(clippy::too_many_arguments)]
199
pub async fn start_run_scoped(
200
    app: &Shared,
201
    row: &db::repos::RepoRow,
202
    bookmark: &str,
203
    state: &str,
204
    heads: &[ChangeHash],
205
    actor: &str,
206
    trigger: &str,
207
    touched: Option<&[String]>,
208
) -> Result<Option<i64>, String> {
209
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
210
}
211
212
/// Start a run of the pipeline in the state `heads` make (whose state
213
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
214
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
215
/// whose access the run's subrepo reads are checked against. Returns the
216
/// run's number, or `None` when the state has no pipeline. The caller
217
/// checks that Flow is enabled.
218
pub async fn start_run(
219
    app: &Shared,
220
    row: &db::repos::RepoRow,
221
    bookmark: &str,
222
    state: &str,
223
    heads: &[ChangeHash],
224
    actor: &str,
225
    trigger: &str,
226
) -> Result<Option<i64>, String> {
227
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
228
}
229
230
#[allow(clippy::too_many_arguments)]
231
async fn start_run_with(
232
    app: &Shared,
233
    row: &db::repos::RepoRow,
234
    bookmark: &str,
235
    state: &str,
236
    heads: &[ChangeHash],
237
    actor: &str,
238
    trigger: &str,
239
    touched: Option<&[String]>,
240
) -> Result<Option<i64>, String> {
241
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None).await
242
}
243
244
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock.
245
#[allow(clippy::too_many_arguments)]
246
async fn start_run_at(
247
    app: &Shared,
248
    row: &db::repos::RepoRow,
249
    bookmark: &str,
250
    state: &str,
251
    heads: &[ChangeHash],
252
    actor: &str,
253
    trigger: &str,
254
    touched: Option<&[String]>,
255
    at: Option<flow_core::pipeline::When>,
256
) -> Result<Option<i64>, String> {
257
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
258
    let h = heads.to_vec();
259
    let text = app
260
        .repos
261
        .read(&repo, move |r, cache| {
262
            let loaded = cache.load(r, &h)?;
263
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
264
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
265
                _ => None,
266
            })
267
        })
268
        .await
269
        .map_err(|e| format!("reading the pipeline: {e}"))?;
270
    let Some(text) = text else { return Ok(None) };
271
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
272
    hex.sort();
273
    let heads_s = hex.join("+");
274
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
275
    let id = match Pipeline::parse(&text) {
276
        Err(problems) => {
277
            let msg = problems.to_string();
278
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
279
        }
280
        Ok(p) => {
281
            let depths = p.depths();
282
            // Jobs out of the push's scope stay out, unless a kept job
283
            // needs them.
284
            // The jobs this run's event starts (`on`), out of those its
285
            // push touched (`scope`); then what they depend on.
286
            let event = match at {
287
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
288
                None => event_of(trigger, bookmark),
289
            };
290
            let mut keep: std::collections::HashSet<String> = p
291
                .order()
292
                .into_iter()
293
                .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched))
294
                .map(|j| j.name.clone())
295
                .collect();
296
            loop {
297
                let needed: Vec<String> = p
298
                    .order()
299
                    .into_iter()
300
                    .filter(|j| keep.contains(&j.name))
301
                    .flat_map(|j| j.depends_on.iter().cloned())
302
                    .filter(|d| !keep.contains(d))
303
                    .collect();
304
                if needed.is_empty() {
305
                    break;
306
                }
307
                keep.extend(needed);
308
            }
309
            let jobs: Vec<fdb::NewJob> = p
310
                .order()
311
                .into_iter()
312
                .filter(|j| keep.contains(&j.name))
313
                .map(|j| {
314
                    let sb = &p.sandboxes[&j.sandbox];
315
                    fdb::NewJob {
316
                        name: j.name.clone(),
317
                        depth: depths[j.name.as_str()] as i64,
318
                        depends: j.depends_on.clone(),
319
                        spec: serde_json::to_string(j).unwrap_or_default(),
320
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
321
                        runtime: sb.runtime.as_str().into(),
322
                    }
323
                })
324
                .collect();
325
            if jobs.is_empty() {
326
                // Nothing this event starts (or this push touched).
327
                return Ok(None);
328
            }
329
            let hash = hex::encode(p.hash());
330
            let id =
331
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
332
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
333
                for j in &jobs {
334
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
335
                }
336
            }
337
            id
338
        }
339
    };
340
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
341
    Ok(Some(run.number))
342
}
343
344
/// Record a job's check; it links to the run's page by the run's number.
345
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
346
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
347
    let name = format!("flow/{job}");
348
    let c = db::hooks::upsert_check(
349
        &app.db,
350
        row.id,
351
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
352
    )
353
    .await;
354
    if let Ok(c) = c {
355
        let api = crate::api::check_of(&c);
356
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
357
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
358
    }
359
}
360
361
/// The check status a job's status shows as.
362
fn check_status(job_status: &str) -> &'static str {
363
    match job_status {
364
        "queued" => "queued",
365
        "running" => "running",
366
        "passed" => "passed",
367
        "skipped" => "skipped",
368
        _ => "failed",
369
    }
370
}
371
372
/// The runner a bearer token belongs to: a registered runner, or one
373
/// configured with a hub.toml token (tracked from its first request). A
374
/// disabled runner is refused.
375
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
376
    let Some(t) = oxy_server::bearer_token(headers) else {
377
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
378
    };
379
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
380
    let runner = match db::runners::by_token(&app.db, &hash).await? {
381
        Some(r) => r,
382
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
383
            db::runners::configured(&app.db, &hash, "runner").await?
384
        }
385
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
386
    };
387
    if runner.state == "disabled" {
388
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
389
    }
390
    Ok(runner)
391
}
392
393
/// `POST /api/v1/flow/runners/register`: a one-time registration token
394
/// from Admin → Runners buys a runner credential, binding the runner's
395
/// signing identity (hub spec §13.2).
396
pub async fn register(
397
    State(app): State<Shared>,
398
    Json(req): Json<flow_core::api::Register>,
399
) -> Result<Json<flow_core::api::Registered>, ApiError> {
400
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
401
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
402
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
403
    if name.is_empty() {
404
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
405
    }
406
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
407
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
408
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
409
    let runner =
410
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
411
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
412
        })?;
413
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
414
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
415
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
416
}
417
418
/// `POST /api/v1/flow/claim`.
419
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
420
    let me = runner_auth(&app, &headers).await?;
421
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
422
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
423
    if let Some(holding) = &req.holding {
424
        end_jobs_not_held(&app, &me, holding).await?;
425
    }
426
    // A draining runner finishes what it has and takes nothing new.
427
    if me.state == "draining" {
428
        return Ok(StatusCode::NO_CONTENT.into_response());
429
    }
430
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
431
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
432
    let room = fdb::Room {
433
        memory_mib: req.free_memory_mib,
434
        cpus: req.free_cpus,
435
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
436
    };
437
    // The labels it reports now, or those it registered with.
438
    let labels: Vec<String> = match &req.info {
439
        Some(i) => i.labels.clone(),
440
        None => me.label_list().into_iter().map(String::from).collect(),
441
    };
442
    let Some(job) =
443
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
444
    else {
445
        return Ok(StatusCode::NO_CONTENT.into_response());
446
    };
447
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
448
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
449
    let row =
450
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
451
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
452
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
453
    // The job's limit: its own timeout, else its sandbox's, else the site
454
    // default, never past the site maximum; recorded for the reaper.
455
    let limits = limits(&app).await;
456
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
457
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
458
    let timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
459
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
460
    let claim = Claim {
461
        job_id: job.id,
462
        run_id: run.id,
463
        repo: format!("{}/{}", row.owner, row.name),
464
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
465
        token: secret,
466
        heads: run.head_list().into_iter().map(String::from).collect(),
467
        pipeline_hash: run.pipeline_hash.clone(),
468
        job: spec,
469
        sandbox,
470
        timeout_s,
471
        subrepos: vec![],
472
        state: run.state.clone(),
473
        bookmark: run.bookmark.clone(),
474
        run_number: run.number,
475
    };
476
    let mut claim = claim;
477
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
478
    if !ids.is_empty() {
479
        fdb::set_also_read(&app.db, job.id, &ids).await?;
480
    }
481
    claim.subrepos = inputs;
482
    Ok(Json(claim).into_response())
483
}
484
485
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
486
async fn pins_in(
487
    app: &Shared,
488
    name: crate::repos::RepoName,
489
    heads: Vec<oxy_core::hash::ChangeHash>,
490
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
491
    app.repos
492
        .read(&name, move |repo, cache| {
493
            let loaded = cache.load(repo, &heads)?;
494
            let mut out = Vec::new();
495
            for (path, f) in &loaded.snapshot.files {
496
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
497
                    continue;
498
                }
499
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
500
                    if let Some(b) = blobs.first() {
501
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
502
                            .read_blob(b)
503
                            .map_err(crate::repos::RepoError::Oxy)
504
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
505
                        {
506
                            out.push((path.clone(), id, heads));
507
                        }
508
                    }
509
                }
510
            }
511
            Ok(out)
512
        })
513
        .await
514
        .unwrap_or_default()
515
}
516
517
/// How deep subrepos of subrepos are followed.
518
const MAX_SUBREPO_DEPTH: usize = 3;
519
520
/// The Oxygen subrepos the run's state pins, and those their states pin in
521
/// turn (parents first, paths joined), that live on this hub and that the
522
/// run's pusher may read: what the job's workspace needs beside its own
523
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
524
/// then fails on the missing files with an ordinary error.
525
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
526
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
527
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
528
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
529
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
530
    let mut inputs = Vec::new();
531
    let mut ids = Vec::new();
532
    // (path prefix, repository, heads, depth), parents before children.
533
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
534
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
535
        if depth == MAX_SUBREPO_DEPTH {
536
            continue;
537
        }
538
        for (path, id, pinned) in pins_in(app, name, heads).await {
539
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
540
            for other in &rows {
541
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
542
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
543
                if !matches {
544
                    continue;
545
                }
546
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
547
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
548
                    inputs.push(flow_core::api::SubrepoInput {
549
                        path: path.clone(),
550
                        repo: format!("{}/{}", other.owner, other.name),
551
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
552
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
553
                    });
554
                    if !ids.contains(&other.id) {
555
                        ids.push(other.id);
556
                    }
557
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
558
                }
559
                break;
560
            }
561
        }
562
    }
563
    (inputs, ids)
564
}
565
566
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
567
    match fdb::job(&app.db, id).await? {
568
        Some(j) if j.status == "running" => Ok(j),
569
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
570
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
571
    }
572
}
573
574
/// `POST /api/v1/flow/jobs/{id}/log`.
575
pub async fn log(
576
    State(app): State<Shared>,
577
    headers: HeaderMap,
578
    Path(id): Path<i64>,
579
    Json(mut batch): Json<LogBatch>,
580
) -> Result<StatusCode, ApiError> {
581
    runner_auth(&app, &headers).await?;
582
    let job = running_job(&app, id).await?;
583
    for r in &mut batch.records {
584
        if r.text.len() > MAX_RECORD_TEXT {
585
            let mut cut = MAX_RECORD_TEXT;
586
            while !r.text.is_char_boundary(cut) {
587
                cut -= 1;
588
            }
589
            r.text.truncate(cut);
590
        }
591
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
592
            r.stream = "console".into();
593
        }
594
    }
595
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
596
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
597
        tracing::info!("flow: job {} reached the log limit", job.id);
598
    }
599
    Ok(StatusCode::NO_CONTENT)
600
}
601
602
/// Largest artifact the hub keeps; the runner refuses larger ones first.
603
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
604
605
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
606
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
607
/// repository. Answers `{"blob"}`.
608
pub async fn artifact(
609
    State(app): State<Shared>,
610
    headers: HeaderMap,
611
    Path((id, name)): Path<(i64, String)>,
612
    body: axum::body::Bytes,
613
) -> Result<Response, ApiError> {
614
    runner_auth(&app, &headers).await?;
615
    let job = running_job(&app, id).await?;
616
    let spec: flow_core::Job =
617
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
618
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
619
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
620
    };
621
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
622
    let row =
623
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
624
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
625
    let content = blake3::hash(&body).to_hex().to_string();
626
    let size = body.len() as i64;
627
    let blob = app
628
        .repos
629
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
630
        .await
631
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
632
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
633
    fdb::add_artifact(&app.db, &a).await?;
634
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
635
}
636
637
/// `POST /api/v1/flow/jobs/{id}/finish`.
638
pub async fn finish(
639
    State(app): State<Shared>,
640
    headers: HeaderMap,
641
    Path(id): Path<i64>,
642
    Json(f): Json<Finish>,
643
) -> Result<StatusCode, ApiError> {
644
    let me = runner_auth(&app, &headers).await?;
645
    let job = running_job(&app, id).await?;
646
    let status = match (f.exit_code, &f.error) {
647
        (Some(0), None) => "passed",
648
        (Some(_), _) => "failed",
649
        (None, _) => "error",
650
    };
651
    let (receipt, receipt_hash) = match &f.receipt {
652
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
653
            // The receipt names exactly the artifacts uploaded, in name order.
654
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
655
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
656
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
657
            }
658
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
659
        }
660
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
661
        None => (String::new(), String::new()),
662
    };
663
    let error = f.error.clone().unwrap_or_default();
664
    let changed = fdb::finish(
665
        &app.db,
666
        job.id,
667
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
668
    )
669
    .await?;
670
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
671
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
672
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
673
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
674
                    Ok((attest, signer)) => (attest, signer, String::new()),
675
                    Err(e) => {
676
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
677
                        (String::new(), String::new(), e)
678
                    }
679
                };
680
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
681
            }
682
        }
683
    }
684
    report_changed(&app, job.run_id, changed).await?;
685
    Ok(StatusCode::NO_CONTENT)
686
}
687
688
/// Report the checks of jobs whose status changed.
689
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
690
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
691
        return Ok(());
692
    };
693
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
694
        return Ok(());
695
    };
696
    for j in changed {
697
        let summary = match j.status.as_str() {
698
            "passed" => format!("passed in {}", duration(&j)),
699
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
700
            "skipped" => "skipped: a dependency did not pass".into(),
701
            _ => format!("could not run: {}", j.error),
702
        };
703
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
704
    }
705
    Ok(())
706
}
707
708
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
709
/// Settings, else from `hub.toml`.
710
pub struct Limits {
711
    /// For a job whose pipeline names none.
712
    pub default_s: u64,
713
    /// The most a pipeline may ask for.
714
    pub max_s: u64,
715
    /// Grace past a job's limit before the hub gives it up: a runner stops
716
    /// a job at its limit and reports it within moments, so one still
717
    /// running this long after is one the runner no longer reports (it was
718
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
719
    pub grace_ms: i64,
720
}
721
722
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
723
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
724
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
725
726
pub async fn limits(app: &Shared) -> Limits {
727
    let minutes = |key: &'static str| async move {
728
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
729
    };
730
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
731
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
732
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
733
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
734
}
735
736
/// End every job still running past its time limit plus the grace:
737
/// it is marked an error with the reason, the jobs that depended on it are
738
/// skipped and its run settled, as when a runner reports an error.
739
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
740
    let limits = limits(app).await;
741
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
742
    let n = lost.len();
743
    for job in lost {
744
        let error = format!(
745
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
746
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
747
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
748
        );
749
        let changed =
750
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
751
                .await?;
752
        tracing::warn!("flow: job {} {error}", job.id);
753
        report_changed(app, job.run_id, changed).await?;
754
    }
755
    Ok(n)
756
}
757
758
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
759
/// it restarted since claiming them, or never received the claim. A
760
/// runner claims one job at a time, so a job it holds is always listed.
761
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
762
    for job in fdb::running_on(&app.db, runner.id).await? {
763
        if holding.contains(&job.id) {
764
            continue;
765
        }
766
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
767
        let changed =
768
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
769
                .await?;
770
        tracing::warn!("flow: job {} {error}", job.id);
771
        report_changed(app, job.run_id, changed).await?;
772
    }
773
    Ok(())
774
}
775
776
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
777
/// repository that runs Flow, the jobs of its default bookmark's pipeline
778
/// with a `schedule` line due then. Returns how many runs started.
779
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
780
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
781
    let mut started = 0;
782
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
783
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
784
        let b = row.default_bookmark.clone();
785
        let found = app
786
            .repos
787
            .read(&repo, move |r, _| {
788
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
789
                Ok(Some((st, r.store().get_state(&st)?.heads)))
790
            })
791
            .await;
792
        let Ok(Some((state, heads))) = found else { continue };
793
        if heads.is_empty() {
794
            continue;
795
        }
796
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when)).await {
797
            Ok(Some(_)) => started += 1,
798
            Ok(None) => {}
799
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
800
        }
801
    }
802
    started
803
}
804
805
/// The hub's clock as a [`When`](flow_core::pipeline::When).
806
pub fn when_now() -> flow_core::pipeline::When {
807
    let now = time::OffsetDateTime::now_utc();
808
    flow_core::pipeline::When {
809
        minute: now.minute() as u32,
810
        hour: now.hour() as u32,
811
        day: now.day() as u32,
812
        month: u8::from(now.month()) as u32,
813
        weekday: now.weekday().number_days_from_sunday() as u32,
814
    }
815
}
816
817
/// Start the scheduled runs due each minute, once a minute.
818
pub async fn scheduler(app: Shared) {
819
    let mut last = None;
820
    loop {
821
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
822
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
823
        if last == Some(minute) {
824
            continue;
825
        }
826
        last = Some(minute);
827
        let n = run_due(&app, when_now()).await;
828
        if n > 0 {
829
            tracing::info!("flow: {n} scheduled run(s) started");
830
        }
831
    }
832
}
833
834
/// Look for lost jobs once a minute.
835
pub async fn reaper(app: Shared) {
836
    loop {
837
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
838
        if let Err(e) = reap_lost_jobs(&app).await {
839
            tracing::warn!("flow: looking for lost jobs: {e}");
840
        }
841
    }
842
}
843
844
/// Check a runner's signed receipt for a job of `run` and keep it in the
845
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
846
/// name, or why it was not kept. It must be the job's receipt, for the
847
/// run's state and repository, signed by the key the runner's token first
848
/// signed with.
849
pub async fn keep_receipt(
850
    app: &Shared,
851
    runner: &db::runners::Runner,
852
    row: &db::repos::RepoRow,
853
    run: &fdb::Run,
854
    receipt: &flow_core::Receipt,
855
    signed_hex: &str,
856
) -> Result<(String, String), String> {
857
    use oxy_core::receipt::SignedReceipt;
858
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
859
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
860
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
861
    if sr.receipt.hash().0 != receipt.hash() {
862
        return Err("it signs another receipt than the job's".into());
863
    }
864
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
865
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
866
    if sr.state() != state {
867
        return Err("it is for another state than the run's".into());
868
    }
869
    let key = sr.runner.hash().to_hex();
870
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
871
    if bound != key {
872
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
873
    }
874
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
875
    let signer = sr.runner.name.clone();
876
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
877
    Ok((attest.to_hex(), signer))
878
}
879
880
/// A job's wall time, as the pages show it.
881
pub fn duration(j: &fdb::Job) -> String {
882
    match (j.started_ms, j.finished_ms) {
883
        (Some(a), Some(b)) => human_ms(b - a),
884
        (Some(a), None) => human_ms(db::now_ms() - a),
885
        _ => "–".into(),
886
    }
887
}
888
889
pub fn human_ms(ms: i64) -> String {
890
    let ms = ms.max(0);
891
    if ms < 1000 {
892
        format!("{ms} ms")
893
    } else if ms < 60_000 {
894
        format!("{:.1} s", ms as f64 / 1000.0)
895
    } else {
896
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
897
    }
898
}
899
900
/// Where a live run stands among the runners' work: what its viewers may
901
/// know of it. Jobs of other repositories are named only when those are
902
/// public; the rest are counted.
903
#[derive(Clone, Debug, Default, PartialEq, Eq)]
904
pub struct QueueInfo {
905
    /// 1-based place of the run's first queued job among all queued jobs.
906
    pub position: Option<usize>,
907
    /// Jobs running now, and queued jobs ahead of the run's first one.
908
    pub running: Vec<String>,
909
    pub ahead: Vec<String>,
910
    /// Of those, jobs in repositories the page may not name.
911
    pub hidden: usize,
912
}
913
914
impl QueueInfo {
915
    /// "3rd in line" and the like.
916
    pub fn place(&self) -> Option<String> {
917
        self.position.map(|n| {
918
            let suffix = match (n % 10, n % 100) {
919
                (1, 11) | (2, 12) | (3, 13) => "th",
920
                (1, _) => "st",
921
                (2, _) => "nd",
922
                (3, _) => "rd",
923
                _ => "th",
924
            };
925
            format!("{n}{suffix} in line")
926
        })
927
    }
928
}
929
930
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
931
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
932
    let mut info = QueueInfo::default();
933
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
934
    info.position = first.map(|i| i + 1);
935
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
936
    let mut queued_seen = 0;
937
    for e in queue {
938
        if e.run_id == run_id {
939
            continue;
940
        }
941
        let ahead = match e.status.as_str() {
942
            "running" => true,
943
            _ => {
944
                queued_seen += 1;
945
                first.is_some_and(|f| queued_seen <= f)
946
            }
947
        };
948
        if !ahead {
949
            continue;
950
        }
951
        if e.repo_id != repo_id && e.visibility != "public" {
952
            info.hidden += 1;
953
        } else if e.status == "running" {
954
            info.running.push(label(e));
955
        } else {
956
            info.ahead.push(label(e));
957
        }
958
    }
959
    info
960
}
961
962
#[cfg(test)]
963
mod queue_tests {
964
    use super::*;
965
966
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
967
        fdb::QueueEntry {
968
            job_id,
969
            run_id,
970
            repo_id,
971
            owner: "o".into(),
972
            name: format!("r{repo_id}"),
973
            visibility: vis.into(),
974
            run_number: run_id,
975
            job: job.into(),
976
            status: status.into(),
977
            runner: String::new(),
978
            spec: String::new(),
979
            sandbox: String::new(),
980
        }
981
    }
982
983
    #[test]
984
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
985
        // Running first, then queued in claim order (as `fdb::queue` returns them).
986
        let q = vec![
987
            e(1, 10, 1, "public", "build", "running"),
988
            e(2, 11, 2, "private", "test", "running"),
989
            e(3, 10, 1, "public", "test", "queued"),
990
            e(4, 12, 3, "private", "lint", "queued"),
991
            e(5, 13, 4, "public", "build", "queued"),
992
            e(6, 13, 4, "public", "test", "queued"),
993
            e(7, 14, 5, "public", "late", "queued"),
994
        ];
995
        let info = queue_info(&q, 4, 13);
996
        assert_eq!(info.position, Some(3));
997
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
998
        assert_eq!(info.running, ["o/r1 #10 build"]);
999
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
1000
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
1001
        // A private repository's own jobs are named to its viewers.
1002
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
1003
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
1004
        // A run with nothing queued has no place.
1005
        assert_eq!(queue_info(&q, 1, 99).position, None);
1006
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
1007
        assert_eq!(
1008
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1009
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
1010
        );
1011
    }
1012
}