OXYGENHUB
# dc59d1b56c3d oxygenhub / crates / hub / src / flow.rs
106.1 KiB · Rust · last changed Approvers are told; oh run approve and oh run reject 20 days 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
        // `own`: on, run only by the repository's or its owner's runners (#225).
27
        "on" | "own" => true,
28
        "off" => false,
29
        _ => app.config.flow.allows(&row.owner, &row.name),
30
    }
31
}
32
33
/// A bookmark moved by a push: start a run when the repository is allowed
34
/// and the new state has a pipeline.
35
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
36
    if heads.is_empty() || !enabled(app, row) {
37
        return;
38
    }
39
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
40
    let started = match bookmark.strip_prefix("tags/") {
41
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
42
        None => {
43
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
44
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
45
            if let Ok(Some(n)) = started {
46
                supersede(app, row, bookmark, n).await;
47
            }
48
            started
49
        }
50
    };
51
    if let Err(e) = started {
52
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
53
    }
54
}
55
56
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
57
/// moving it. Returns the new run's number.
58
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
59
/// the jobs they depend on.
60
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
61
    if !enabled(app, row) {
62
        return Err("OxygenFlow is not enabled for this repository".into());
63
    }
64
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
65
    let b = bookmark.to_string();
66
    let found = app
67
        .repos
68
        .read(&repo, move |r, _| {
69
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
70
            Ok(Some((st, r.store().get_state(&st)?.heads)))
71
        })
72
        .await
73
        .map_err(|e| e.to_string())?;
74
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
75
    if heads.is_empty() {
76
        return Err(format!("{bookmark} has no changes yet"));
77
    }
78
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
79
        .await?
80
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
81
}
82
83
/// "Run pipeline" on one committed change: the state whose only head is
84
/// `change`, whatever bookmarks hold it. Returns the new run's number.
85
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
86
    if !enabled(app, row) {
87
        return Err("OxygenFlow is not enabled for this repository".into());
88
    }
89
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
90
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
91
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
92
    if !known {
93
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
94
    }
95
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
96
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
97
        .await?
98
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
99
}
100
101
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
102
/// run whose checks replace the old ones. Returns the new run's number.
103
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
104
    if !enabled(app, row) {
105
        return Err("OxygenFlow is not enabled for this repository".into());
106
    }
107
    if run.live() {
108
        return Err(format!("run #{} is still {}", run.number, run.status));
109
    }
110
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
111
    if heads.is_empty() {
112
        return Err("the run names no changes".into());
113
    }
114
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
115
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
116
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
117
        .await?
118
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
119
}
120
121
/// Run one job of a finished run again (oxygenflow#31), with the jobs
122
/// that depend on it; the jobs it depends on keep their results, so a
123
/// flaky test reruns without the build before it. Its checks go back to
124
/// queued. Returns how many jobs were queued again.
125
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
126
    if !enabled(app, row) {
127
        return Err("OxygenFlow is not enabled for this repository".into());
128
    }
129
    if run.live() {
130
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
131
    }
132
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
133
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
134
        "the job could not be queued again".to_string()
135
    })?;
136
    if changed.is_empty() {
137
        return Err(format!("run #{} has no job {job:?}", run.number));
138
    }
139
    for j in &changed {
140
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
141
    }
142
    Ok(changed.len())
143
}
144
145
/// Cancel a run still going (oxygenflow#32), its checks with it.
146
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
147
    if !run.live() {
148
        return Err(format!("run #{} is already {}", run.number, run.status));
149
    }
150
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
151
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
152
        "the run could not be cancelled".to_string()
153
    })
154
}
155
156
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
157
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
158
    report_changed(app, run.id, changed).await
159
}
160
161
/// Why job `job` of `run` cannot be decided on by `who` (oxygenflow#57),
162
/// or its approval: it must be waiting, the jobs it depends on done and
163
/// passed, `who` must have the role it names on the repository, and with
164
/// others-only must not be the person whose push started the run.
165
pub async fn may_decide(
166
    app: &Shared,
167
    row: &db::repos::RepoRow,
168
    run: &fdb::Run,
169
    job: &fdb::Job,
170
    who: &db::accounts::Account,
171
) -> Result<flow_core::Approval, String> {
172
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
173
    let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) };
174
    if job.status != "queued" || job.approval != "waiting" {
175
        return Err(format!("{} is not waiting for approval", job.name));
176
    }
177
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
178
    if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) {
179
        return Err(format!("{} waits for {d} first", job.name));
180
    }
181
    let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain);
182
    let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?;
183
    if role.is_none_or(|r| r < need) {
184
        return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name));
185
    }
186
    if approval.others_only && who.username == run.actor {
187
        return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor));
188
    }
189
    Ok(approval)
190
}
191
192
/// Approve or reject job `name` of `run` as `who` (oxygenflow#57): the
193
/// decision goes on the job, its log and the audit log; a rejected job ends
194
/// as cancelled and the jobs after it are skipped. What happened, or why not.
195
pub async fn decide(
196
    app: &Shared,
197
    row: &db::repos::RepoRow,
198
    run: &fdb::Run,
199
    name: &str,
200
    who: &db::accounts::Account,
201
    approve: bool,
202
) -> Result<String, String> {
203
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
204
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
205
    may_decide(app, row, run, &job, who).await?;
206
    let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number);
207
    let said = if approve {
208
        if !fdb::approve_job(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())? {
209
            return Err(format!("{name} was decided on meanwhile"));
210
        }
211
        db::accounts::audit(&app.db, Some(who), "flow.approved", &target, "").await.ok();
212
        format!("approved by {}", who.username)
213
    } else {
214
        fdb::note_rejection(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())?;
215
        let why = format!("rejected by {}", who.username);
216
        let changed =
217
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
218
                .await
219
                .map_err(|e| e.to_string())?;
220
        report_changed(app, run.id, changed).await.ok();
221
        db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, "").await.ok();
222
        why
223
    };
224
    let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() };
225
    fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
226
    Ok(format!("{name}: {said}"))
227
}
228
229
/// Tell the people who may approve a job of `run` that has just become
230
/// ready (its dependencies all passed) and waits for them (oxygenflow#57).
231
/// Told once: the job's log notes it.
232
async fn tell_approvers(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
233
    const TOLD: &str = "waiting for approval; the people who may approve were told";
234
    let Ok(jobs) = fdb::jobs(&app.db, run.id).await else { return };
235
    for job in jobs.iter().filter(|j| j.status == "queued" && j.approval == "waiting") {
236
        if !job.depends_on().iter().all(|d| jobs.iter().any(|j| &j.name == d && j.status == "passed")) {
237
            continue;
238
        }
239
        let already = fdb::log(&app.db, job.id, 0, 50).await.unwrap_or_default().iter().any(|l| l.text == TOLD);
240
        if already {
241
            continue;
242
        }
243
        let mut targets = Vec::new();
244
        for a in db::accounts::accounts(&app.db).await.unwrap_or_default().iter().filter(|a| !a.blocked()) {
245
            if may_decide(app, row, run, job, a).await.is_ok() {
246
                targets.push((a.id, db::notify::Reason::Participating));
247
            }
248
        }
249
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: TOLD.into() };
250
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
251
        let title = format!("{}/{} run #{}: {} waits for your approval", row.owner, row.name, run.number, job.name);
252
        let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number);
253
        crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await;
254
    }
255
}
256
257
/// End the approvals that waited too long (oxygenflow#57): from when the
258
/// jobs a waiting job depends on finished (or its run began).
259
pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> {
260
    let mut ended = 0;
261
    for job in fdb::waiting_for_approval(&app.db).await? {
262
        let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue };
263
        let Some(approval) = spec.approval else { continue };
264
        let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue };
265
        let jobs = fdb::jobs(&app.db, run.id).await?;
266
        let wanted = job.depends_on();
267
        let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect();
268
        if deps.iter().any(|d| d.status != "passed") {
269
            continue;
270
        }
271
        let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms);
272
        if now - since < approval.expires_s as i64 * 1000 {
273
            continue;
274
        }
275
        let hours = approval.expires_s as f64 / 3600.0;
276
        let why =
277
            format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) });
278
        let changed =
279
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
280
                .await?;
281
        report_changed(app, run.id, changed).await?;
282
        ended += 1;
283
    }
284
    Ok(ended)
285
}
286
287
/// A push makes the push runs still going on its bookmark moot: they are
288
/// cancelled, and the new run's scope reaches back past them.
289
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
290
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
291
    for run in older {
292
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
293
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
294
        }
295
    }
296
}
297
298
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
299
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
300
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
301
    use flow_core::pipeline::Event;
302
    let t = match trigger.strip_prefix("rerun:") {
303
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
304
        None => trigger,
305
    };
306
    if let Some(tag) = t.strip_prefix("tag:") {
307
        Event::Tag(tag)
308
    } else if t == "push" {
309
        Event::Push(bookmark)
310
    } else if t == "schedule" {
311
        Event::Schedule
312
    } else {
313
        Event::Manual
314
    }
315
}
316
317
/// The event to repeat when rerunning `run`.
318
fn original_event(run: &fdb::Run) -> String {
319
    let t = run.trigger.as_str();
320
    match t.strip_prefix("rerun:") {
321
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
322
        None => t.to_string(),
323
    }
324
}
325
326
/// The paths the changes a push brought touch: the changes in `heads`'
327
/// state that the bookmark's previous run did not have, and the files their
328
/// edits and tree operations name, as the new state places them. `None`
329
/// (run every job) for the bookmark's first run, or when it cannot be told.
330
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
331
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
332
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
333
    if old.is_empty() {
334
        return None;
335
    }
336
    let repo = RepoName::new(&row.owner, &row.name)?;
337
    let new = heads.to_vec();
338
    app.repos
339
        .read(&repo, move |r, cache| {
340
            let fresh = r.negotiate(&old, &new)?;
341
            let loaded = cache.load(r, &new)?;
342
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
343
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
344
            let mut paths = std::collections::BTreeSet::new();
345
            for h in fresh {
346
                let (c, _) = r.store().get_change_lazy(&h)?;
347
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
348
                for n in nodes {
349
                    if let Some(p) = by_node.get(&n) {
350
                        paths.insert((*p).clone());
351
                    }
352
                }
353
            }
354
            Ok(paths.into_iter().collect::<Vec<String>>())
355
        })
356
        .await
357
        .ok()
358
}
359
360
/// Whether a job runs for a push that touched `touched`: always without a
361
/// scope or without knowing what was touched, else when a path matches.
362
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
363
    let Some(touched) = touched else { return true };
364
    if scope.is_empty() {
365
        return true;
366
    }
367
    let mut b = globset::GlobSetBuilder::new();
368
    for g in scope {
369
        match globset::Glob::new(g) {
370
            Ok(g) => {
371
                b.add(g);
372
            }
373
            Err(_) => return true,
374
        }
375
    }
376
    let Ok(set) = b.build() else { return true };
377
    touched.iter().any(|p| set.is_match(p))
378
}
379
380
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
381
/// left out of the run, unless a job that runs depends on them.
382
#[allow(clippy::too_many_arguments)]
383
pub async fn start_run_scoped(
384
    app: &Shared,
385
    row: &db::repos::RepoRow,
386
    bookmark: &str,
387
    state: &str,
388
    heads: &[ChangeHash],
389
    actor: &str,
390
    trigger: &str,
391
    touched: Option<&[String]>,
392
) -> Result<Option<i64>, String> {
393
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
394
}
395
396
/// Start a run of the pipeline in the state `heads` make (whose state
397
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
398
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
399
/// whose access the run's subrepo reads are checked against. Returns the
400
/// run's number, or `None` when the state has no pipeline. The caller
401
/// checks that Flow is enabled.
402
pub async fn start_run(
403
    app: &Shared,
404
    row: &db::repos::RepoRow,
405
    bookmark: &str,
406
    state: &str,
407
    heads: &[ChangeHash],
408
    actor: &str,
409
    trigger: &str,
410
) -> Result<Option<i64>, String> {
411
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
412
}
413
414
#[allow(clippy::too_many_arguments)]
415
async fn start_run_with(
416
    app: &Shared,
417
    row: &db::repos::RepoRow,
418
    bookmark: &str,
419
    state: &str,
420
    heads: &[ChangeHash],
421
    actor: &str,
422
    trigger: &str,
423
    touched: Option<&[String]>,
424
) -> Result<Option<i64>, String> {
425
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
426
}
427
428
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
429
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
430
#[allow(clippy::too_many_arguments)]
431
async fn start_run_at(
432
    app: &Shared,
433
    row: &db::repos::RepoRow,
434
    bookmark: &str,
435
    state: &str,
436
    heads: &[ChangeHash],
437
    actor: &str,
438
    trigger: &str,
439
    touched: Option<&[String]>,
440
    at: Option<flow_core::pipeline::When>,
441
    only: &[String],
442
) -> Result<Option<i64>, String> {
443
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
444
    let h = heads.to_vec();
445
    let text = app
446
        .repos
447
        .read(&repo, move |r, cache| {
448
            let loaded = cache.load(r, &h)?;
449
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
450
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
451
                _ => None,
452
            })
453
        })
454
        .await
455
        .map_err(|e| format!("reading the pipeline: {e}"))?;
456
    let Some(text) = text else { return Ok(None) };
457
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
458
    hex.sort();
459
    let heads_s = hex.join("+");
460
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
461
    let id = match Pipeline::parse(&text) {
462
        Err(problems) => {
463
            let msg = problems.to_string();
464
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
465
        }
466
        Ok(p) => {
467
            let depths = p.depths();
468
            // Jobs out of the push's scope stay out, unless a kept job
469
            // needs them.
470
            // The jobs this run's event starts (`on`), out of those its
471
            // push touched (`scope`); then what they depend on.
472
            let event = match at {
473
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
474
                None => event_of(trigger, bookmark),
475
            };
476
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
477
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
478
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
479
            } else {
480
                // Named jobs (oxygenhub#130): each one this event starts.
481
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
482
                for name in only {
483
                    if !startable.contains(&name.as_str()) {
484
                        let what = if p.jobs.contains_key(name) {
485
                            "is not started by hand (no `on { manual }`)"
486
                        } else {
487
                            "is not in the pipeline"
488
                        };
489
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
490
                    }
491
                }
492
                only.iter().cloned().collect()
493
            };
494
            loop {
495
                let needed: Vec<String> = p
496
                    .order()
497
                    .into_iter()
498
                    .filter(|j| keep.contains(&j.name))
499
                    .flat_map(|j| j.depends_on.iter().cloned())
500
                    .filter(|d| !keep.contains(d))
501
                    .collect();
502
                if needed.is_empty() {
503
                    break;
504
                }
505
                keep.extend(needed);
506
            }
507
            let mut jobs: Vec<fdb::NewJob> = p
508
                .order()
509
                .into_iter()
510
                .filter(|j| keep.contains(&j.name))
511
                .map(|j| {
512
                    let sb = &p.sandboxes[&j.sandbox];
513
                    fdb::NewJob {
514
                        name: j.name.clone(),
515
                        depth: depths[j.name.as_str()] as i64,
516
                        depends: j.depends_on.clone(),
517
                        spec: serde_json::to_string(j).unwrap_or_default(),
518
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
519
                        runtime: sb.runtime.as_str().into(),
520
                        need_disk_mib: None,
521
                    }
522
                })
523
                .collect();
524
            // What each job needs, learned from its past runs (oxygenflow#41).
525
            for j in &mut jobs {
526
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
527
            }
528
            if jobs.is_empty() {
529
                // Nothing this event starts (or this push touched).
530
                return Ok(None);
531
            }
532
            let hash = hex::encode(p.hash());
533
            let id =
534
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
535
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
536
                for j in &jobs {
537
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
538
                }
539
                images_check(app, row, state, run.number, &jobs).await;
540
            }
541
            id
542
        }
543
    };
544
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
545
    Ok(Some(run.number))
546
}
547
548
/// The `pipeline/images` check (#137): set failing when an image the run's
549
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
550
/// one says so before it lands rather than its runs queueing for good;
551
/// passed again by a later run of the state once every image is held.
552
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
553
    let mut pinned: Vec<String> = jobs
554
        .iter()
555
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
556
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
557
        .filter(|r| !r.is_empty())
558
        .collect();
559
    pinned.sort();
560
    pinned.dedup();
561
    if pinned.is_empty() {
562
        return;
563
    }
564
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
565
    // Nobody can be said to lack an image unless some runner is known and
566
    // every one reports what it holds.
567
    let runners: Vec<db::runners::Runner> =
568
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
569
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
570
        return;
571
    }
572
    for r in &runners {
573
        held.extend(r.image_list().unwrap_or_default());
574
    }
575
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
576
    // Only a missing image makes the check; one that was failing on this
577
    // state turns to passed once its images are held.
578
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
579
        .await
580
        .unwrap_or_default()
581
        .iter()
582
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
583
    if missing.is_empty() && !failing_before {
584
        return;
585
    }
586
    let (status, summary) = if missing.is_empty() {
587
        (
588
            "passed",
589
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
590
        )
591
    } else {
592
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
593
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
594
    };
595
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
596
    if let Ok(c) = db::hooks::upsert_check(
597
        &app.db,
598
        row.id,
599
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
600
    )
601
    .await
602
    {
603
        let api = crate::api::check_of(&c);
604
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
605
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
606
    }
607
}
608
609
/// Record a job's check; it links to the run's page by the run's number.
610
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
611
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
612
    let name = format!("flow/{job}");
613
    let c = db::hooks::upsert_check(
614
        &app.db,
615
        row.id,
616
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
617
    )
618
    .await;
619
    if let Ok(c) = c {
620
        let api = crate::api::check_of(&c);
621
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
622
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
623
    }
624
}
625
626
/// The check status a job's status shows as.
627
fn check_status(job_status: &str) -> &'static str {
628
    match job_status {
629
        "queued" => "queued",
630
        "running" => "running",
631
        "passed" => "passed",
632
        "skipped" => "skipped",
633
        "cancelled" => "cancelled",
634
        _ => "failed",
635
    }
636
}
637
638
/// The runner a bearer token belongs to: a registered runner, or one
639
/// configured with a hub.toml token (tracked from its first request). A
640
/// disabled runner is refused.
641
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
642
    let Some(t) = oxy_server::bearer_token(headers) else {
643
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
644
    };
645
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
646
    let runner = match db::runners::by_token(&app.db, &hash).await? {
647
        Some(r) => r,
648
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
649
            db::runners::configured(&app.db, &hash, "runner").await?
650
        }
651
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
652
    };
653
    if runner.state == "disabled" {
654
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
655
    }
656
    Ok(runner)
657
}
658
659
/// `POST /api/v1/flow/runners/register`: a one-time registration token
660
/// from Admin → Runners buys a runner credential, binding the runner's
661
/// signing identity (hub spec §13.2).
662
pub async fn register(
663
    State(app): State<Shared>,
664
    Json(req): Json<flow_core::api::Register>,
665
) -> Result<Json<flow_core::api::Registered>, ApiError> {
666
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
667
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
668
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
669
    if name.is_empty() {
670
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
671
    }
672
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
673
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
674
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
675
    let runner =
676
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
677
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
678
        })?;
679
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
680
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
681
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
682
}
683
684
/// `POST /api/v1/flow/claim`.
685
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
686
    let me = runner_auth(&app, &headers).await?;
687
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
688
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
689
    if let Some(holding) = &req.holding {
690
        end_jobs_not_held(&app, &me, holding).await?;
691
    }
692
    // A draining runner finishes what it has and takes nothing new.
693
    if me.state == "draining" {
694
        return Ok(StatusCode::NO_CONTENT.into_response());
695
    }
696
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
697
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
698
    // What it holds and what it is (OxygenFlow spec §11): the images'
699
    // architectures it reports are remembered for every runner's claims.
700
    if let Some(archs) = &req.image_archs {
701
        fdb::record_image_archs(&app.db, archs).await?;
702
    }
703
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
704
    // What it offers, kept for the placement pass; a job it declined is
705
    // not offered to it again while this stays the same (spec §11.5).
706
    let profile = profile_of(&req, &arch);
707
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
708
    if let Some(f) = &req.facts {
709
        db::runners::set_facts(&app.db, me.id, f).await?;
710
    }
711
    app.swarm.claimed(me.id, req.swarm.as_ref());
712
    // The build caches it holds (oxygenflow#51), and when each last kept a
713
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
714
    if let Some(c) = &req.caches {
715
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
716
        CACHES
717
            .lock()
718
            .unwrap_or_else(|p| p.into_inner())
719
            .get_or_insert_with(Default::default)
720
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
721
    }
722
    let room = fdb::Room {
723
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
724
        memory_mib: req.free_memory_mib,
725
        cpus: req.free_cpus,
726
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
727
        arch,
728
        image_archs: fdb::image_archs(&app.db).await?,
729
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
730
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
731
    };
732
    // The labels it reports now, or those it registered with, and those
733
    // an administrator gave it (#98).
734
    let mut labels: Vec<String> = match &req.info {
735
        Some(i) => i.labels.clone(),
736
        None => me.reported_label_list().into_iter().map(String::from).collect(),
737
    };
738
    for l in me.admin_label_list() {
739
        if !labels.iter().any(|x| x == l) {
740
            labels.push(l.to_string());
741
        }
742
    }
743
    // Ranking (oxygenflow#42): a job another free runner would do better
744
    // (it holds the image, or is faster) is held for it briefly.
745
    let runners = db::runners::list(&app.db).await?;
746
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
747
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
748
    let queue = fdb::queue(&app.db).await?;
749
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
750
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
751
    let slots: std::collections::HashMap<i64, String> =
752
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
753
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
754
    let now = db::now_ms();
755
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
756
    let hub_key = app.repos.root().display().to_string();
757
    let better = |j: &fdb::Job| -> bool {
758
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
759
        let rootfs = rootfs_of(j);
760
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
761
        let holds = holds.get_or_insert_with(Default::default);
762
        holds.retain(|_, t| now - *t < 600_000);
763
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
764
            return false;
765
        }
766
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
767
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
768
        let other = runners.iter().any(|r| {
769
            r.id != me.id
770
                && r.state == "active"
771
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
772
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
773
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
774
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
775
        });
776
        if other {
777
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
778
        }
779
        other
780
    };
781
    let Some(job) =
782
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
783
    else {
784
        return Ok(StatusCode::NO_CONTENT.into_response());
785
    };
786
    // Why here (oxygenflow#42).
787
    let waited =
788
        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);
789
    let rootfs = rootfs_of(&job);
790
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
791
        "holds the image".to_string()
792
    } else {
793
        "fetches the image from the hub".to_string()
794
    }];
795
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
796
        why.insert(
797
            0,
798
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
799
        );
800
    }
801
    match running.get(&me.id).copied().unwrap_or(0) {
802
        0 => why.push("idle".into()),
803
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
804
    }
805
    if let Some(sp) = me_now.speed {
806
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
807
    }
808
    if let Some(w) = waited {
809
        why.push(format!("after {} s held for a better runner", w / 1000));
810
    }
811
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
812
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
813
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
814
    let row =
815
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
816
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
817
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
818
    // The job's limit: its own timeout, else its sandbox's, else the site
819
    // default, never past the site maximum; recorded for the reaper.
820
    let limits = limits(&app).await;
821
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
822
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
823
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
824
    // ...and never past the runner's own limit, when it has one (#98).
825
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
826
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
827
    }
828
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
829
    let claim = Claim {
830
        job_id: job.id,
831
        run_id: run.id,
832
        repo: format!("{}/{}", row.owner, row.name),
833
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
834
        token: secret,
835
        heads: run.head_list().into_iter().map(String::from).collect(),
836
        pipeline_hash: run.pipeline_hash.clone(),
837
        job: spec,
838
        sandbox,
839
        timeout_s,
840
        subrepos: vec![],
841
        state: run.state.clone(),
842
        bookmark: run.bookmark.clone(),
843
        run_number: run.number,
844
        // The site's, the owner's and the repository's variables (#102).
845
        env: crate::variables::env_for_repo(&app.db, &row).await?,
846
        secrets: vec![],
847
        inputs: vec![],
848
    };
849
    let mut claim = claim;
850
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
851
    // The secrets the job declares, as files (#107); one not set is said
852
    // at the top of the log and left out, so the job fails on the file.
853
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
854
    let found = crate::secrets::for_job(&app, &row, &names).await?;
855
    // The identities it asks for (#103): a token each, as a file.
856
    if !claim.job.identities.is_empty() {
857
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
858
        for i in &claim.job.identities {
859
            let token = issuer.mint(&crate::oidc::JobClaims {
860
                issuer: app.config.base_url(),
861
                audience: &i.audience,
862
                owner: &row.owner,
863
                name: &row.name,
864
                visibility: &row.visibility,
865
                bookmark: &run.bookmark,
866
                run_number: run.number,
867
                job: &job.name,
868
                state: &run.state,
869
                runner: &runner,
870
                actor: &run.actor,
871
                now_ms: db::now_ms(),
872
                ttl_s: timeout_s,
873
            });
874
            claim.secrets.push(flow_core::api::SecretInput {
875
                name: format!("identity:{}", i.audience),
876
                path: i.mount_path.clone(),
877
                value: token,
878
            });
879
        }
880
    }
881
    for s in &claim.job.secrets {
882
        match found.iter().find(|(n, _)| *n == s.name) {
883
            Some((name, value)) => {
884
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
885
            }
886
            None => left_out.push(format!(
887
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
888
                s.name, s.mount_path
889
            )),
890
        }
891
    }
892
    if !ids.is_empty() {
893
        fdb::set_also_read(&app.db, job.id, &ids).await?;
894
    }
895
    // Say at the top of the job's log what it will not have (oxygenhub#56),
896
    // rather than let it fail later on missing files.
897
    if !left_out.is_empty() {
898
        let records: Vec<flow_core::api::Record> =
899
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
900
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
901
    }
902
    claim.subrepos = inputs;
903
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
904
    // producer's receipt names; the runner checks what it fetches against it.
905
    match artifact_inputs(&app, &job, &claim.job).await {
906
        Ok(found) => claim.inputs = found,
907
        Err(why) => {
908
            let error = format!("inputs: {why}");
909
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
910
                .await?;
911
            return Err(ApiError(StatusCode::CONFLICT, error));
912
        }
913
    }
914
    Ok(Json(claim).into_response())
915
}
916
917
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
918
/// run that passed with a receipt; a passing job's receipt names exactly the
919
/// artifacts it uploaded (checked at finish), so their blobs are attested.
920
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
921
    if spec.inputs.is_empty() {
922
        return Ok(vec![]);
923
    }
924
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
925
    let mut out = Vec::new();
926
    for i in &spec.inputs {
927
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
928
            return Err(format!("{:?} is not a job of this run", i.from));
929
        };
930
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
931
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
932
        }
933
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
934
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
935
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
936
        };
937
        out.push(flow_core::api::ArtifactInput {
938
            artifact: i.artifact.clone(),
939
            from: i.from.clone(),
940
            path: i.path.clone(),
941
            blob: a.blob.clone(),
942
        });
943
    }
944
    Ok(out)
945
}
946
947
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
948
/// of a running job (oxygenflow#58), for the runner holding it.
949
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
950
    let runner = runner_auth(&app, &headers).await?;
951
    let job = running_job(&app, id).await?;
952
    // A registered runner reads only the inputs of a job it holds (a
953
    // configured token's runner names itself when it claims).
954
    if runner.source == "registered" && job.runner != runner.name {
955
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
956
    }
957
    let spec: flow_core::Job =
958
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
959
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
960
    let Some(i) = found.get(index) else {
961
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
962
    };
963
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
964
    let row =
965
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
966
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
967
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
968
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
969
    let bytes = app
970
        .repos
971
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
972
        .await
973
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
974
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
975
}
976
977
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
978
/// labels and capacity, each list sorted.
979
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
980
    let sorted = |v: &[String]| {
981
        let mut v = v.to_vec();
982
        v.sort();
983
        v
984
    };
985
    let info = req.info.as_ref();
986
    let v = serde_json::json!({
987
        "arch": arch,
988
        "tiers": sorted(&req.runtimes),
989
        "images": req.images.as_deref().map(sorted),
990
        "labels": info.map(|i| sorted(&i.labels)),
991
        "memory_mib": info.and_then(|i| i.memory_mib),
992
        "cpus": info.and_then(|i| i.cpus),
993
    });
994
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
995
}
996
997
/// A job handed back by this many different runners is unplaceable.
998
pub const UNPLACEABLE_AFTER: usize = 3;
999
1000
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1001
/// runner will not run a job it was handed. The job is queued again and
1002
/// not offered to that runner while its profile stays the same (and for
1003
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
1004
/// different runners have declined it, it ends as unplaceable with their
1005
/// reasons. A decline is never a failed check.
1006
pub async fn decline(
1007
    State(app): State<Shared>,
1008
    headers: HeaderMap,
1009
    Path(id): Path<i64>,
1010
    Json(d): Json<flow_core::api::Decline>,
1011
) -> Result<StatusCode, ApiError> {
1012
    let me = runner_auth(&app, &headers).await?;
1013
    db::runners::seen(&app.db, me.id, None, None).await?;
1014
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1015
    let reason = match reason.trim() {
1016
        "" => "no reason given".to_string(),
1017
        r => r.to_string(),
1018
    };
1019
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
1020
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1021
    };
1022
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
1023
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1024
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
1025
    if runners.len() >= UNPLACEABLE_AFTER {
1026
        let error = format!(
1027
            "unplaceable: declined by {} runners: {}",
1028
            runners.len(),
1029
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1030
        );
1031
        let changed =
1032
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1033
                .await?;
1034
        report_changed(&app, job.run_id, changed).await?;
1035
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1036
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1037
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1038
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1039
            fdb::set_waiting(&app.db, id, &summary).await?;
1040
        }
1041
    }
1042
    Ok(StatusCode::NO_CONTENT)
1043
}
1044
1045
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1046
/// now (spec §11.4), from what each runner offered at its last claim:
1047
/// empty when one could (it waits its turn or a free slot), else what
1048
/// each one lacks. `declines` are the job's.
1049
/// What placement knows of the fleet: images' architectures, the images
1050
/// the hub holds, and the time.
1051
pub struct Fleet<'a> {
1052
    pub image_archs: &'a std::collections::HashMap<String, String>,
1053
    pub hub_images: &'a std::collections::HashSet<String>,
1054
    pub now: i64,
1055
}
1056
1057
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1058
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1059
/// when it can. `declines` are the job's.
1060
pub fn runner_lacks(
1061
    job: &fdb::Job,
1062
    owner: &str,
1063
    repo_id: i64,
1064
    r: &db::runners::Runner,
1065
    fleet: &Fleet,
1066
    declines: &[fdb::DeclineRow],
1067
) -> Option<String> {
1068
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1069
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1070
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1071
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1072
    if r.state == "draining" {
1073
        return Some("is draining".into());
1074
    }
1075
    let scope = r.scope();
1076
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1077
        return Some("serves other repositories".into());
1078
    }
1079
    let tiers = r.tier_list();
1080
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1081
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1082
    }
1083
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1084
        return Some(format!("is {}; the job needs {w}", r.arch));
1085
    }
1086
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1087
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1088
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1089
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1090
    }
1091
    let labels = r.label_list();
1092
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1093
        return Some(format!("lacks label {l}"));
1094
    }
1095
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1096
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1097
    }
1098
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1099
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1100
    }
1101
    // The disk it learned it needs (oxygenflow#41).
1102
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1103
        if free < n {
1104
            return Some(format!(
1105
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1106
                mib(free),
1107
                mib(n),
1108
                mib((n - 64) * 4 / 5)
1109
            ));
1110
        }
1111
    }
1112
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1113
        return Some(format!("declined it: {}", d.reason));
1114
    }
1115
    None
1116
}
1117
1118
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1119
/// now (spec §11.4), from what each runner offered at its last claim:
1120
/// empty when one could (it waits its turn or a free slot), else what
1121
/// each one lacks. `declines` are the job's.
1122
#[allow(clippy::too_many_arguments)]
1123
pub fn why_waiting(
1124
    job: &fdb::Job,
1125
    owner: &str,
1126
    repo_id: i64,
1127
    runners: &[db::runners::Runner],
1128
    image_archs: &std::collections::HashMap<String, String>,
1129
    declines: &[fdb::DeclineRow],
1130
    now: i64,
1131
    hub_images: &std::collections::HashSet<String>,
1132
) -> String {
1133
    let fleet = Fleet { image_archs, hub_images, now };
1134
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1135
    if online.is_empty() {
1136
        return "no runner is online".into();
1137
    }
1138
    let mut why = Vec::new();
1139
    for r in &online {
1140
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
1141
            None => return String::new(),
1142
            Some(w) => why.push(format!("{} {w}", r.name)),
1143
        }
1144
    }
1145
    let more = why.len().saturating_sub(4);
1146
    why.truncate(4);
1147
    let mut text = format!("no runner can take it: {}", why.join("; "));
1148
    if more > 0 {
1149
        text.push_str(&format!("; and {more} more"));
1150
    }
1151
    text
1152
}
1153
1154
/// How long a job is held for a better runner before any runner that fits
1155
/// takes it (oxygenflow#42).
1156
const BETTER_HOLD_MS: i64 = 15_000;
1157
/// A runner counts as there to take a job when it asked this recently.
1158
const POLLING_MS: i64 = 10_000;
1159
1160
/// When each job was first passed over for a better runner.
1161
/// Keyed by hub (tests run several in one process) and job.
1162
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1163
1164
/// The build cache slots each runner said at its last claim it holds
1165
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1166
/// runner; a restart forgets them until each runner's next claim.
1167
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1168
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1169
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1170
1171
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1172
/// it did not say); `None` when it holds none there.
1173
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1174
    CACHES
1175
        .lock()
1176
        .unwrap_or_else(|p| p.into_inner())
1177
        .as_ref()
1178
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1179
        .and_then(|held| held.get(slot).copied())
1180
}
1181
1182
/// A copy of a build cache kept at least this much later is the fresher
1183
/// (oxygenflow#54): closer than that, the two are as good as each other.
1184
const FRESHER_MS: i64 = 60_000;
1185
1186
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1187
struct Fit {
1188
    /// When it last kept the job's build cache, if it holds it.
1189
    cache: Option<i64>,
1190
    image: bool,
1191
    speed: i64,
1192
}
1193
1194
impl Fit {
1195
    /// Whether this fit is better than `other`: holding the job's build
1196
    /// cache counts most (a warm build beats any image fetch), and of two
1197
    /// that hold it the fresher copy; then holding the image, then speed.
1198
    fn beats(&self, other: &Fit) -> bool {
1199
        match (self.cache, other.cache) {
1200
            (Some(_), None) => return true,
1201
            (None, Some(_)) => return false,
1202
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1203
            _ => {}
1204
        }
1205
        (self.image, self.speed) > (other.image, other.speed)
1206
    }
1207
}
1208
1209
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1210
/// `slot`.
1211
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1212
    Fit {
1213
        cache: cache_kept(hub, r.id, slot),
1214
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1215
        speed: r.speed_class.unwrap_or(0),
1216
    }
1217
}
1218
1219
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1220
fn ago(ms: i64) -> String {
1221
    let s = ms.max(0) / 1000;
1222
    match s {
1223
        0..=59 => format!("{s} s"),
1224
        60..=3599 => format!("{} min", s / 60),
1225
        3600..=172_799 => format!("{} h", s / 3600),
1226
        _ => format!("{} days", s / 86_400),
1227
    }
1228
}
1229
1230
fn rootfs_of(job: &fdb::Job) -> String {
1231
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1232
}
1233
1234
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1235
fn mib(m: i64) -> String {
1236
    if m >= 1024 {
1237
        format!("{:.1} GiB", m as f64 / 1024.0)
1238
    } else {
1239
        format!("{m} MiB")
1240
    }
1241
}
1242
1243
/// One placement pass: say why each ready queued job waits.
1244
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1245
    let ready = fdb::ready_queued(&app.db).await?;
1246
    if ready.is_empty() {
1247
        return Ok(());
1248
    }
1249
    let runners = db::runners::list(&app.db).await?;
1250
    let archs = fdb::image_archs(&app.db).await?;
1251
    let held = crate::images::held(&app.db).await?;
1252
    let now = db::now_ms();
1253
    for (job, owner, repo_id) in ready {
1254
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1255
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1256
        if why == job.waiting {
1257
            continue;
1258
        }
1259
        fdb::set_waiting(&app.db, job.id, &why).await?;
1260
        // The job's check says it too, so a proposal shows why it is stuck
1261
        // (oxygenhub#28).
1262
        if let (Some(run), Some(row)) = (fdb::run_by_id(&app.db, job.run_id).await?, db::repos::get_by_id(&app.db, repo_id).await?) {
1263
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1264
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1265
        }
1266
    }
1267
    Ok(())
1268
}
1269
1270
/// Run a placement pass every 15 seconds.
1271
pub async fn placement(app: Shared) {
1272
    loop {
1273
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1274
        if let Err(e) = explain_waits(&app).await {
1275
            tracing::warn!("flow: placement pass: {e}");
1276
        }
1277
    }
1278
}
1279
1280
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1281
pub(crate) async fn pins_in(
1282
    app: &Shared,
1283
    name: crate::repos::RepoName,
1284
    heads: Vec<oxy_core::hash::ChangeHash>,
1285
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
1286
    app.repos
1287
        .read(&name, move |repo, cache| {
1288
            let loaded = cache.load(repo, &heads)?;
1289
            let mut out = Vec::new();
1290
            for (path, f) in &loaded.snapshot.files {
1291
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1292
                    continue;
1293
                }
1294
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1295
                    if let Some(b) = blobs.first() {
1296
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1297
                            .read_blob(b)
1298
                            .map_err(crate::repos::RepoError::Oxy)
1299
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1300
                        {
1301
                            out.push((path.clone(), id, heads));
1302
                        }
1303
                    }
1304
                }
1305
            }
1306
            Ok(out)
1307
        })
1308
        .await
1309
        .unwrap_or_default()
1310
}
1311
1312
/// How deep subrepos of subrepos are followed.
1313
const MAX_SUBREPO_DEPTH: usize = 3;
1314
1315
/// The Oxygen subrepos the run's state pins, and those their states pin in
1316
/// turn (parents first, paths joined), that live on this hub and that the
1317
/// run's pusher may read: what the job's workspace needs beside its own
1318
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1319
/// the third list says why, for the job's log.
1320
async fn subrepo_inputs(
1321
    app: &Shared,
1322
    row: &db::repos::RepoRow,
1323
    run: &fdb::Run,
1324
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1325
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1326
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1327
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1328
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1329
    let mut inputs = Vec::new();
1330
    let mut ids = Vec::new();
1331
    let mut left_out = Vec::new();
1332
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1333
    // (path prefix, repository, heads, depth), parents before children.
1334
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1335
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1336
        if depth == MAX_SUBREPO_DEPTH {
1337
            continue;
1338
        }
1339
        for (path, id, pinned) in pins_in(app, name, heads).await {
1340
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1341
            let mut hosted = false;
1342
            for other in &rows {
1343
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1344
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1345
                if !matches {
1346
                    continue;
1347
                }
1348
                hosted = true;
1349
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1350
                // A Diffusion bump's run reads the upstream it bumps: the one
1351
                // the consumer pins (someone who may write it committed the
1352
                // pin), and no other (#150).
1353
                let diffused = run.actor == crate::diffusion::ACCOUNT
1354
                    && run.bookmark.starts_with("diffusion/")
1355
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id = ?")
1356
                        .bind(run.repo_id)
1357
                        .bind(other.id)
1358
                        .fetch_one(&app.db)
1359
                        .await
1360
                        .unwrap_or(0)
1361
                        > 0;
1362
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1363
                    inputs.push(flow_core::api::SubrepoInput {
1364
                        path: path.clone(),
1365
                        repo: format!("{}/{}", other.owner, other.name),
1366
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1367
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1368
                    });
1369
                    if !ids.contains(&other.id) {
1370
                        ids.push(other.id);
1371
                    }
1372
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1373
                } else {
1374
                    left_out.push(format!(
1375
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1376
                        other.owner, other.name
1377
                    ));
1378
                }
1379
                break;
1380
            }
1381
            if !hosted {
1382
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1383
            }
1384
        }
1385
    }
1386
    (inputs, ids, left_out)
1387
}
1388
1389
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1390
    match fdb::job(&app.db, id).await? {
1391
        Some(j) if j.status == "running" => Ok(j),
1392
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1393
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1394
    }
1395
}
1396
1397
/// `POST /api/v1/flow/jobs/{id}/log`.
1398
pub async fn log(
1399
    State(app): State<Shared>,
1400
    headers: HeaderMap,
1401
    Path(id): Path<i64>,
1402
    Json(mut batch): Json<LogBatch>,
1403
) -> Result<StatusCode, ApiError> {
1404
    runner_auth(&app, &headers).await?;
1405
    let job = running_job(&app, id).await?;
1406
    // An empty batch asks only whether the job still runs: a cancelled
1407
    // one answers 409 and its runner stops it (oxygenflow#32).
1408
    if batch.records.is_empty() {
1409
        return Ok(StatusCode::NO_CONTENT);
1410
    }
1411
    // A secret's value never reaches the stored log (#107).
1412
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1413
        Ok(spec) if !spec.secrets.is_empty() => {
1414
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1415
            let row = match run {
1416
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1417
                None => None,
1418
            };
1419
            match row {
1420
                Some(row) => {
1421
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1422
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1423
                }
1424
                None => vec![],
1425
            }
1426
        }
1427
        _ => vec![],
1428
    };
1429
    let kid = app.oidc.get().map(|i| i.kid.clone());
1430
    for r in &mut batch.records {
1431
        if !values.is_empty() {
1432
            r.text = crate::secrets::mask(&r.text, &values);
1433
        }
1434
        if let Some(kid) = &kid {
1435
            r.text = crate::oidc::mask(&r.text, kid);
1436
        }
1437
        if r.text.len() > MAX_RECORD_TEXT {
1438
            let mut cut = MAX_RECORD_TEXT;
1439
            while !r.text.is_char_boundary(cut) {
1440
                cut -= 1;
1441
            }
1442
            r.text.truncate(cut);
1443
        }
1444
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
1445
            r.stream = "console".into();
1446
        }
1447
    }
1448
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
1449
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
1450
        tracing::info!("flow: job {} reached the log limit", job.id);
1451
    }
1452
    Ok(StatusCode::NO_CONTENT)
1453
}
1454
1455
/// Largest artifact the hub keeps; the runner refuses larger ones first.
1456
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
1457
1458
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
1459
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
1460
/// repository. Answers `{"blob"}`.
1461
pub async fn artifact(
1462
    State(app): State<Shared>,
1463
    headers: HeaderMap,
1464
    Path((id, name)): Path<(i64, String)>,
1465
    body: axum::body::Bytes,
1466
) -> Result<Response, ApiError> {
1467
    runner_auth(&app, &headers).await?;
1468
    let job = running_job(&app, id).await?;
1469
    let spec: flow_core::Job =
1470
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1471
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
1472
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
1473
    };
1474
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1475
    let row =
1476
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1477
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1478
    // A report's artifact is its manifest (#127): checked, and its files
1479
    // found in the store, before it is kept.
1480
    let report = match out.kind {
1481
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
1482
        flow_core::OutputKind::Artifact => None,
1483
    };
1484
    let content = blake3::hash(&body).to_hex().to_string();
1485
    let size = body.len() as i64;
1486
    let artifact = report.is_none();
1487
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
1488
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
1489
    let blob = app
1490
        .repos
1491
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1492
        .await
1493
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
1494
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
1495
    fdb::add_artifact(&app.db, &a).await?;
1496
    if let Some((manifest, declaration)) = report {
1497
        let origin = crate::reports::Origin {
1498
            repo_id: row.id,
1499
            run_id: run.id,
1500
            run_number: run.number,
1501
            job_id: job.id,
1502
            job: &job.name,
1503
            bookmark: &run.bookmark,
1504
            state: &run.state,
1505
        };
1506
        let said = match declaration {
1507
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
1508
                Ok(id) => {
1509
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
1510
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
1511
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
1512
                }
1513
                Err(e) => format!("report {}: not indexed: {e}", a.name),
1514
            },
1515
            Err(why) => {
1516
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
1517
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
1518
                format!("report {}: report.toml: {why}", a.name)
1519
            }
1520
        };
1521
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1522
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1523
    }
1524
    // A coverage report (#113) is read now; what it says, or why it could
1525
    // not be read, goes in the job's log. It never fails the upload.
1526
    if let Some(bytes) = report_bytes {
1527
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
1528
            Ok(text) | Err(text) => text,
1529
        };
1530
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1531
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1532
    }
1533
    // A test report (#114) likewise.
1534
    if let Some(bytes) = junit_bytes {
1535
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
1536
            Ok(text) | Err(text) => text,
1537
        };
1538
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
1539
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
1540
    }
1541
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1542
}
1543
1544
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1545
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1546
/// `fail_above` or `fail_below`, which a proposal may require.
1547
async fn report_threshold_checks(
1548
    app: &Shared,
1549
    row: &db::repos::RepoRow,
1550
    run: &fdb::Run,
1551
    name: &str,
1552
    id: i64,
1553
    metrics: &[crate::reports::Metric],
1554
) {
1555
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1556
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1557
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1558
        let above = m.fail_above.filter(|a| m.value > *a);
1559
        let below = m.fail_below.filter(|b| m.value < *b);
1560
        let (status, summary) = match (above, below) {
1561
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1562
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1563
            _ => {
1564
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1565
                    .into_iter()
1566
                    .flatten()
1567
                    .collect();
1568
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1569
            }
1570
        };
1571
        let check = format!("report/{name}/{}", m.key);
1572
        if let Ok(c) = db::hooks::upsert_check(
1573
            &app.db,
1574
            row.id,
1575
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1576
        )
1577
        .await
1578
        {
1579
            let api = crate::api::check_of(&c);
1580
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1581
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1582
        }
1583
    }
1584
}
1585
1586
/// Check report manifest `body` (#127) and that the repository holds every
1587
/// file it names; answers it with the report's declaration, or why that
1588
/// could not be read.
1589
async fn report_manifest(
1590
    app: &Shared,
1591
    repo: &RepoName,
1592
    name: &str,
1593
    body: &[u8],
1594
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
1595
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
1596
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
1597
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
1598
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
1599
    let found = app
1600
        .repos
1601
        .read(repo, move |r, _| {
1602
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
1603
                return Ok(Err(path.clone()));
1604
            }
1605
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
1606
        })
1607
        .await
1608
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
1609
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
1610
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
1611
    Ok((manifest, declaration))
1612
}
1613
1614
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
1615
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
1616
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
1617
/// the files follows as the report's artifact.
1618
pub async fn report_file(
1619
    State(app): State<Shared>,
1620
    headers: HeaderMap,
1621
    Path((id, name)): Path<(i64, String)>,
1622
    body: axum::body::Bytes,
1623
) -> Result<Response, ApiError> {
1624
    runner_auth(&app, &headers).await?;
1625
    let job = running_job(&app, id).await?;
1626
    let spec: flow_core::Job =
1627
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1628
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
1629
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
1630
    }
1631
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
1632
    if !crate::reports::path_ok(path) {
1633
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
1634
    }
1635
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1636
    let row =
1637
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1638
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1639
    let blob = app
1640
        .repos
1641
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1642
        .await
1643
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
1644
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
1645
}
1646
1647
/// Parse and keep a job's JUnit report: what it says, or why not.
1648
async fn read_junit(
1649
    app: &Shared,
1650
    row: &db::repos::RepoRow,
1651
    run: &fdb::Run,
1652
    job: &fdb::Job,
1653
    blob: &str,
1654
    bytes: axum::body::Bytes,
1655
) -> Result<String, String> {
1656
    if bytes.len() > crate::junit::MAX_REPORT {
1657
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
1658
    }
1659
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
1660
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
1661
        .await
1662
        .map_err(|e| format!("tests: {e}"))?
1663
        .map_err(|e| format!("tests: {e}"))?;
1664
    let origin = crate::junit::Origin {
1665
        repo_id: row.id,
1666
        run_id: run.id,
1667
        run_number: run.number,
1668
        job_id: job.id,
1669
        job: &job.name,
1670
        bookmark: &run.bookmark,
1671
        state: &run.state,
1672
        blob,
1673
    };
1674
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1675
    let r = crate::junit::for_run(&app.db, run.id)
1676
        .await
1677
        .map_err(|e| format!("tests: {e}"))?
1678
        .into_iter()
1679
        .find(|r| r.job_id == job.id)
1680
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1681
    // The `tests` check on the run's state: what fails, and which of those
1682
    // passed in the same job's latest report on the default bookmark, so a
1683
    // proposal names what it broke.
1684
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1685
    let newly = match &base {
1686
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1687
        _ => vec![],
1688
    };
1689
    let (status, summary) = if r.broken() == 0 {
1690
        ("passed", format!("{} passed", r.passed))
1691
    } else if let Some(b) = &base {
1692
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1693
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1694
        let new = if newly.is_empty() {
1695
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1696
        } else {
1697
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1698
        };
1699
        ("failed", format!("{} failing; {new}", r.broken()))
1700
    } else {
1701
        ("failed", format!("{} failing", r.broken()))
1702
    };
1703
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1704
    if let Ok(c) = db::hooks::upsert_check(
1705
        &app.db,
1706
        row.id,
1707
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1708
    )
1709
    .await
1710
    {
1711
        let api = crate::api::check_of(&c);
1712
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1713
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1714
    }
1715
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1716
}
1717
1718
/// Parse and keep a job's coverage report: what it says, or why not.
1719
async fn read_coverage(
1720
    app: &Shared,
1721
    repo: &RepoName,
1722
    row: &db::repos::RepoRow,
1723
    run: &fdb::Run,
1724
    job: &fdb::Job,
1725
    blob: &str,
1726
    bytes: axum::body::Bytes,
1727
) -> Result<String, String> {
1728
    if bytes.len() > crate::coverage::MAX_REPORT {
1729
        return Err(format!(
1730
            "coverage: the report is {} MiB; at most {} MiB is read",
1731
            bytes.len() >> 20,
1732
            crate::coverage::MAX_REPORT >> 20
1733
        ));
1734
    }
1735
    let text = String::from_utf8(bytes.to_vec())
1736
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
1737
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
1738
        .await
1739
        .map_err(|e| format!("coverage: {e}"))?
1740
        .map_err(|e| format!("coverage: {e}"))?;
1741
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
1742
    let in_state: std::collections::HashSet<String> = app
1743
        .repos
1744
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
1745
        .await
1746
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
1747
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1748
    let origin = crate::coverage::Origin {
1749
        repo_id: row.id,
1750
        run_id: Some(run.id),
1751
        run_number: run.number,
1752
        job_id: Some(job.id),
1753
        uploader: "",
1754
        job: &job.name,
1755
        bookmark: &run.bookmark,
1756
        state: &run.state,
1757
        heads: &heads,
1758
        blob,
1759
        format,
1760
    };
1761
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1762
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1763
        return Err("coverage: the report was not kept".into());
1764
    };
1765
    // The `coverage` check on the run's state (#113): against the default
1766
    // bookmark, under the repository's rules.
1767
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1768
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1769
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1770
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1771
    if let Ok(c) = db::hooks::upsert_check(
1772
        &app.db,
1773
        row.id,
1774
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1775
    )
1776
    .await
1777
    {
1778
        let api = crate::api::check_of(&c);
1779
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1780
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1781
    }
1782
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1783
    let dropped =
1784
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
1785
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
1786
}
1787
1788
/// `POST /api/v1/flow/jobs/{id}/finish`.
1789
pub async fn finish(
1790
    State(app): State<Shared>,
1791
    headers: HeaderMap,
1792
    Path(id): Path<i64>,
1793
    Json(f): Json<Finish>,
1794
) -> Result<StatusCode, ApiError> {
1795
    let me = runner_auth(&app, &headers).await?;
1796
    let job = running_job(&app, id).await?;
1797
    let status = match (f.exit_code, &f.error) {
1798
        (Some(0), None) => "passed",
1799
        (Some(_), _) => "failed",
1800
        (None, _) => "error",
1801
    };
1802
    let (receipt, receipt_hash) = match &f.receipt {
1803
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
1804
            // A passing job's receipt names exactly the artifacts uploaded,
1805
            // in name order; a failing one's names none, though it may have
1806
            // kept `when "always"` artifacts (oxygenflow#37).
1807
            let kept: Vec<String> =
1808
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
1809
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
1810
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
1811
            }
1812
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
1813
        }
1814
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
1815
        None => (String::new(), String::new()),
1816
    };
1817
    let error = f.error.clone().unwrap_or_default();
1818
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
1819
    let changed = fdb::finish(
1820
        &app.db,
1821
        job.id,
1822
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
1823
    )
1824
    .await?;
1825
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1826
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1827
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
1828
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
1829
                    Ok((attest, signer)) => (attest, signer, String::new()),
1830
                    Err(e) => {
1831
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
1832
                        (String::new(), String::new(), e)
1833
                    }
1834
                };
1835
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
1836
            }
1837
        }
1838
    }
1839
    report_changed(&app, job.run_id, changed).await?;
1840
    Ok(StatusCode::NO_CONTENT)
1841
}
1842
1843
/// Report the checks of jobs whose status changed.
1844
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
1845
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
1846
        return Ok(());
1847
    };
1848
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1849
        return Ok(());
1850
    };
1851
    let mut errored_on: Vec<i64> = Vec::new();
1852
    let some_passed = changed.iter().any(|j| j.status == "passed");
1853
    for j in changed {
1854
        let summary = match j.status.as_str() {
1855
            "passed" => format!("passed in {}", duration(&j)),
1856
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
1857
            "skipped" => "skipped: a dependency did not pass".into(),
1858
            "cancelled" => j.error.clone(),
1859
            _ => format!("could not run: {}", j.error),
1860
        };
1861
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1862
        if j.status == "error" {
1863
            if let Some(r) = runner_of(app, j.id).await {
1864
                errored_on.push(r);
1865
            }
1866
        }
1867
    }
1868
    errored_on.dedup();
1869
    for r in errored_on {
1870
        drain_if_erroring(app, r).await;
1871
    }
1872
    if some_passed {
1873
        tell_approvers(app, &row, &run).await;
1874
    }
1875
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1876
        if done.status == "failed" && !done.live() {
1877
            tell_failed(app, &row, &done).await;
1878
        }
1879
    }
1880
    // A passing run of an upstream's default bookmark: consumers following
1881
    // it when green are bumped (Diffusion, #148).
1882
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1883
        if done.status == "passed" && done.bookmark == row.default_bookmark {
1884
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1885
            let (app, row) = (app.clone(), row.clone());
1886
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
1887
        }
1888
        // A bump's run passed: a consumer that lands its bumps itself may
1889
        // land it now (docs/diffusion.md §4.2).
1890
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
1891
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
1892
        }
1893
    }
1894
    Ok(())
1895
}
1896
1897
/// The account OxygenFlow's notices come from, made on first use with no
1898
/// way to sign in.
1899
pub const ACCOUNT: &str = "oxygenflow";
1900
1901
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
1902
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
1903
        return Some(a);
1904
    }
1905
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
1906
}
1907
1908
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
1909
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = ?").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
1910
}
1911
1912
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
1913
/// are told, in their inboxes and by email as they chose, naming the jobs
1914
/// that did not pass.
1915
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
1916
    let Some(me) = flow_account(&app.db).await else { return };
1917
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
1918
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
1919
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1920
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
1921
    crate::notify::deliver(
1922
        app,
1923
        crate::notify::Event {
1924
            kind: "flow.failed",
1925
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
1926
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1927
            actor: &me,
1928
            repo_id: row.id,
1929
            thread: None,
1930
            mention_text: "",
1931
            direct: pusher.into_iter().map(|a| a.id).collect(),
1932
        },
1933
    )
1934
    .await;
1935
}
1936
1937
/// A runner whose last jobs all ended in infrastructure errors is drained,
1938
/// so it stops taking jobs it fails before running them, and the
1939
/// administrators are told (oxygenflow#30).
1940
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
1941
    let n = app.config.flow.drain_after_errors;
1942
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
1943
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
1944
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
1945
        return;
1946
    }
1947
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
1948
    let Some(me) = flow_account(&app.db).await else { return };
1949
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default();
1950
    crate::notify::deliver(
1951
        app,
1952
        crate::notify::Event {
1953
            kind: "flow.runner_drained",
1954
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
1955
            url: format!("/admin/runners/{runner_id}"),
1956
            actor: &me,
1957
            repo_id: 0,
1958
            thread: None,
1959
            mention_text: "",
1960
            direct: admins,
1961
        },
1962
    )
1963
    .await;
1964
}
1965
1966
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
1967
/// Settings, else from `hub.toml`.
1968
pub struct Limits {
1969
    /// For a job whose pipeline names none.
1970
    pub default_s: u64,
1971
    /// The most a pipeline may ask for.
1972
    pub max_s: u64,
1973
    /// Grace past a job's limit before the hub gives it up: a runner stops
1974
    /// a job at its limit and reports it within moments, so one still
1975
    /// running this long after is one the runner no longer reports (it was
1976
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
1977
    pub grace_ms: i64,
1978
}
1979
1980
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
1981
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
1982
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
1983
1984
pub async fn limits(app: &Shared) -> Limits {
1985
    let minutes = |key: &'static str| async move {
1986
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
1987
    };
1988
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
1989
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
1990
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
1991
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
1992
}
1993
1994
/// End every job still running past its time limit plus the grace:
1995
/// it is marked an error with the reason, the jobs that depended on it are
1996
/// skipped and its run settled, as when a runner reports an error.
1997
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
1998
    let limits = limits(app).await;
1999
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
2000
    let n = lost.len();
2001
    for job in lost {
2002
        let error = format!(
2003
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
2004
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
2005
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
2006
        );
2007
        let changed =
2008
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2009
                .await?;
2010
        tracing::warn!("flow: job {} {error}", job.id);
2011
        report_changed(app, job.run_id, changed).await?;
2012
    }
2013
    Ok(n)
2014
}
2015
2016
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
2017
/// it restarted since claiming them, or never received the claim. A
2018
/// runner claims one job at a time, so a job it holds is always listed.
2019
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
2020
    for job in fdb::running_on(&app.db, runner.id).await? {
2021
        if holding.contains(&job.id) {
2022
            continue;
2023
        }
2024
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
2025
        let changed =
2026
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2027
                .await?;
2028
        tracing::warn!("flow: job {} {error}", job.id);
2029
        report_changed(app, job.run_id, changed).await?;
2030
    }
2031
    Ok(())
2032
}
2033
2034
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2035
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2036
/// with a `schedule` line due then. Returns how many runs started.
2037
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2038
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2039
    let mut started = 0;
2040
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2041
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2042
        let b = row.default_bookmark.clone();
2043
        let found = app
2044
            .repos
2045
            .read(&repo, move |r, _| {
2046
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2047
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2048
            })
2049
            .await;
2050
        let Ok(Some((state, heads))) = found else { continue };
2051
        if heads.is_empty() {
2052
            continue;
2053
        }
2054
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
2055
            Ok(Some(_)) => started += 1,
2056
            Ok(None) => {}
2057
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2058
        }
2059
    }
2060
    started
2061
}
2062
2063
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2064
pub fn when_now() -> flow_core::pipeline::When {
2065
    let now = time::OffsetDateTime::now_utc();
2066
    flow_core::pipeline::When {
2067
        minute: now.minute() as u32,
2068
        hour: now.hour() as u32,
2069
        day: now.day() as u32,
2070
        month: u8::from(now.month()) as u32,
2071
        weekday: now.weekday().number_days_from_sunday() as u32,
2072
    }
2073
}
2074
2075
/// Start the scheduled runs due each minute, once a minute.
2076
pub async fn scheduler(app: Shared) {
2077
    let mut last = None;
2078
    loop {
2079
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2080
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2081
        if last == Some(minute) {
2082
            continue;
2083
        }
2084
        last = Some(minute);
2085
        let n = run_due(&app, when_now()).await;
2086
        if n > 0 {
2087
            tracing::info!("flow: {n} scheduled run(s) started");
2088
        }
2089
    }
2090
}
2091
2092
/// Look for lost jobs once a minute.
2093
pub async fn reaper(app: Shared) {
2094
    loop {
2095
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2096
        if let Err(e) = reap_lost_jobs(&app).await {
2097
            tracing::warn!("flow: looking for lost jobs: {e}");
2098
        }
2099
        match expire_approvals(&app, db::now_ms()).await {
2100
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2101
            Ok(_) => {}
2102
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2103
        }
2104
    }
2105
}
2106
2107
/// Check a runner's signed receipt for a job of `run` and keep it in the
2108
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2109
/// name, or why it was not kept. It must be the job's receipt, for the
2110
/// run's state and repository, signed by the key the runner's token first
2111
/// signed with.
2112
pub async fn keep_receipt(
2113
    app: &Shared,
2114
    runner: &db::runners::Runner,
2115
    row: &db::repos::RepoRow,
2116
    run: &fdb::Run,
2117
    receipt: &flow_core::Receipt,
2118
    signed_hex: &str,
2119
) -> Result<(String, String), String> {
2120
    use oxy_core::receipt::SignedReceipt;
2121
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2122
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2123
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2124
    if sr.receipt.hash().0 != receipt.hash() {
2125
        return Err("it signs another receipt than the job's".into());
2126
    }
2127
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2128
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2129
    if sr.state() != state {
2130
        return Err("it is for another state than the run's".into());
2131
    }
2132
    let key = sr.runner.hash().to_hex();
2133
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2134
    if bound != key {
2135
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2136
    }
2137
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2138
    let signer = sr.runner.name.clone();
2139
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2140
    Ok((attest.to_hex(), signer))
2141
}
2142
2143
/// A job's wall time, as the pages show it.
2144
pub fn duration(j: &fdb::Job) -> String {
2145
    match (j.started_ms, j.finished_ms) {
2146
        (Some(a), Some(b)) => human_ms(b - a),
2147
        (Some(a), None) => human_ms(db::now_ms() - a),
2148
        _ => "–".into(),
2149
    }
2150
}
2151
2152
pub fn human_ms(ms: i64) -> String {
2153
    let ms = ms.max(0);
2154
    if ms < 1000 {
2155
        format!("{ms} ms")
2156
    } else if ms < 60_000 {
2157
        format!("{:.1} s", ms as f64 / 1000.0)
2158
    } else {
2159
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2160
    }
2161
}
2162
2163
/// Where a live run stands among the runners' work: what its viewers may
2164
/// know of it. Jobs of other repositories are named only when those are
2165
/// public; the rest are counted.
2166
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2167
pub struct QueueInfo {
2168
    /// 1-based place of the run's first queued job among all queued jobs.
2169
    pub position: Option<usize>,
2170
    /// Jobs running now, and queued jobs ahead of the run's first one.
2171
    pub running: Vec<String>,
2172
    pub ahead: Vec<String>,
2173
    /// Of those, jobs in repositories the page may not name.
2174
    pub hidden: usize,
2175
}
2176
2177
impl QueueInfo {
2178
    /// "3rd in line" and the like.
2179
    pub fn place(&self) -> Option<String> {
2180
        self.position.map(|n| {
2181
            let suffix = match (n % 10, n % 100) {
2182
                (1, 11) | (2, 12) | (3, 13) => "th",
2183
                (1, _) => "st",
2184
                (2, _) => "nd",
2185
                (3, _) => "rd",
2186
                _ => "th",
2187
            };
2188
            format!("{n}{suffix} in line")
2189
        })
2190
    }
2191
}
2192
2193
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2194
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2195
    let mut info = QueueInfo::default();
2196
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2197
    info.position = first.map(|i| i + 1);
2198
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2199
    let mut queued_seen = 0;
2200
    for e in queue {
2201
        if e.run_id == run_id {
2202
            continue;
2203
        }
2204
        let ahead = match e.status.as_str() {
2205
            "running" => true,
2206
            _ => {
2207
                queued_seen += 1;
2208
                first.is_some_and(|f| queued_seen <= f)
2209
            }
2210
        };
2211
        if !ahead {
2212
            continue;
2213
        }
2214
        if e.repo_id != repo_id && e.visibility != "public" {
2215
            info.hidden += 1;
2216
        } else if e.status == "running" {
2217
            info.running.push(label(e));
2218
        } else {
2219
            info.ahead.push(label(e));
2220
        }
2221
    }
2222
    info
2223
}
2224
2225
#[cfg(test)]
2226
mod queue_tests {
2227
    use super::*;
2228
2229
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2230
        fdb::QueueEntry {
2231
            job_id,
2232
            run_id,
2233
            repo_id,
2234
            owner: "o".into(),
2235
            name: format!("r{repo_id}"),
2236
            visibility: vis.into(),
2237
            run_number: run_id,
2238
            job: job.into(),
2239
            status: status.into(),
2240
            runner: String::new(),
2241
            spec: String::new(),
2242
            sandbox: String::new(),
2243
        }
2244
    }
2245
2246
    #[test]
2247
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
2248
        // Running first, then queued in claim order (as `fdb::queue` returns them).
2249
        let q = vec![
2250
            e(1, 10, 1, "public", "build", "running"),
2251
            e(2, 11, 2, "private", "test", "running"),
2252
            e(3, 10, 1, "public", "test", "queued"),
2253
            e(4, 12, 3, "private", "lint", "queued"),
2254
            e(5, 13, 4, "public", "build", "queued"),
2255
            e(6, 13, 4, "public", "test", "queued"),
2256
            e(7, 14, 5, "public", "late", "queued"),
2257
        ];
2258
        let info = queue_info(&q, 4, 13);
2259
        assert_eq!(info.position, Some(3));
2260
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
2261
        assert_eq!(info.running, ["o/r1 #10 build"]);
2262
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
2263
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
2264
        // A private repository's own jobs are named to its viewers.
2265
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
2266
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
2267
        // A run with nothing queued has no place.
2268
        assert_eq!(queue_info(&q, 1, 99).position, None);
2269
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
2270
        assert_eq!(
2271
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
2272
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
2273
        );
2274
    }
2275
}
2276
2277
#[cfg(test)]
2278
mod placement_tests {
2279
    use super::*;
2280
2281
    #[test]
2282
    fn a_waiting_job_says_what_each_runner_lacks() {
2283
        use crate::db::runners::Runner;
2284
        let rootfs = "ab".repeat(32);
2285
        let job = fdb::Job {
2286
            id: 7,
2287
            runtime: "syscall".into(),
2288
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
2289
            spec: serde_json::json!({ "name": "build" }).to_string(),
2290
            ..Default::default()
2291
        };
2292
        let now = db::now_ms();
2293
        let runner = |id: i64, name: &str| Runner {
2294
            id,
2295
            name: name.into(),
2296
            state: "active".into(),
2297
            last_seen_ms: Some(now),
2298
            arch: "x86_64".into(),
2299
            tiers: "microvm,syscall".into(),
2300
            images: serde_json::to_string(&[&rootfs]).unwrap(),
2301
            memory_mib: Some(16384),
2302
            cpus: Some(8),
2303
            ..Default::default()
2304
        };
2305
        let archs = std::collections::HashMap::new();
2306
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
2307
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
2308
        };
2309
        assert_eq!(why(&[], &[]), "no runner is online");
2310
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
2311
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
2312
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
2313
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
2314
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
2315
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
2316
        assert_eq!(
2317
            why(&[microvm_only, small, other, offline], &[]),
2318
            "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories"
2319
        );
2320
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
2321
        assert_eq!(
2322
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
2323
            "no runner can take it: narvi is riscv64; the job needs x86_64"
2324
        );
2325
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
2326
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
2327
        let d = fdb::DeclineRow {
2328
            job_id: 7,
2329
            runner_id: 1,
2330
            runner: "earth-1".into(),
2331
            reason: "only 512 MiB of memory is free here".into(),
2332
            ms: now,
2333
        };
2334
        assert_eq!(
2335
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
2336
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
2337
        );
2338
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
2339
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
2340
    }
2341
}