OXYGENHUB

Coverage / crates/hub/src/flow.rs 90.4%

2483 of 2748 lines run, 360 of 906 functions · run #526 on main · run missed

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`.
24838×
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    // A drop never runs Flow (#279), whatever it holds.
26838×
    if row.is_drop() {
271×
        return false;
28837×
    }
29837×
    match row.flow.as_str() {
30
        // `own`: on, run only by the repository's or its owner's runners (#225).
31837×
        "on" | "own" => true,
32814×
        "off" => false,
33806×
        _ => app.config.flow.allows(&row.owner, &row.name),
34
    }
35838×
}
36
37
/// A bookmark moved by a push: start a run when the repository is allowed
38
/// and the new state has a pipeline.
39299×
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
40299×
    if heads.is_empty() || !enabled(app, row) {
41141×
        return;
42158×
    }
43
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
44158×
    let started = match bookmark.strip_prefix("tags/") {
456×
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
46
        None => {
47
            // `[skip flow]` in the newest change it brings (#284): recorded,
48
            // no jobs.
49152×
            if let Some((change, marker)) = skip_asked(app, row, bookmark, heads).await {
501×
                skip_run(app, row, bookmark, state, heads, actor, &change, marker).await;
511×
                return;
52151×
            }
53
            // It supersedes the push runs still going on the bookmark (#267).
54151×
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
55151×
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
56
        }
57
    };
58155×
    if let Err(e) = started {
590×
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
60155×
    }
61297×
}
62
63
/// Whether a job's earlier pass may stand for it on the same state (#265):
64
/// not a job that waits for a person, deploys to an environment, holds
65
/// secrets or asks for identity tokens, whose running is the point.
66316×
fn reusable(job: &flow_core::Job) -> bool {
67316×
    job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty()
68316×
}
69
70
/// The markers that ask a push not to start a run (#284), as the forges
71
/// spell them; matched in any case.
72
pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"];
73
74
/// The marker in `message` that asks for no run, if any. A marker quoted
75
/// in code (between backticks, or in a fenced block) only mentions one
76
/// and does not count.
77159×
pub fn skip_marker(message: &str) -> Option<&'static str> {
78159×
    let m = outside_code(message).to_lowercase();
79471×
    SKIP_MARKERS.into_iter().find(|k| m.contains(k))
80159×
}
81
82
/// `text` without its fenced blocks (lines between ``` fences) and inline
83
/// code spans (between backticks).
84159×
fn outside_code(text: &str) -> String {
85159×
    let mut out = String::new();
86159×
    let mut fenced = false;
87187×
    for line in text.lines() {
88187×
        if line.trim_start().starts_with("```") {
892×
            fenced = !fenced;
902×
            continue;
91185×
        }
92185×
        if fenced {
931×
            continue;
94184×
        }
95
        // Inline spans: every other backtick-separated piece is code; an
96
        // unmatched backtick leaves the rest as text.
97184×
        let pieces: Vec<&str> = line.split('`').collect();
98184×
        let closed = pieces.len() % 2 == 1;
99189×
        for (i, piece) in pieces.iter().enumerate() {
100189×
            let in_code = i % 2 == 1 && (closed || i + 1 < pieces.len());
101189×
            if !in_code {
102187×
                out.push_str(piece);
103187×
                out.push(' ');
104187×
            }
105
        }
106184×
        out.push('\n');
107
    }
108159×
    out
109159×
}
110
111
/// Whether the repository lets a push to `bookmark` skip its run: `any`
112
/// bookmark (the default), `others` than the default one, or `never`.
113155×
pub fn skip_allowed(row: &db::repos::RepoRow, bookmark: &str) -> bool {
114155×
    match row.flow_skip.as_str() {
115155×
        "never" => false,
116154×
        "others" => bookmark != row.default_bookmark,
117151×
        _ => true,
118
    }
119155×
}
120
121
/// The newest change of `heads` (by its clock) when its message asks for
122
/// no run and the repository allows that here: its hash and the marker.
123152×
async fn skip_asked(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<(ChangeHash, &'static str)> {
124152×
    if !skip_allowed(row, bookmark) {
1251×
        return None;
126151×
    }
127151×
    let repo = RepoName::new(&row.owner, &row.name)?;
128151×
    let h = heads.to_vec();
129151×
    let newest = app
130151×
        .repos
131151×
        .read(&repo, move |r, _| {
132151×
            let mut best: Option<(oxy_core::objects::Hlc, ChangeHash, String)> = None;
133196×
            for x in &h {
134196×
                let (c, _) = r.store().get_change_lazy(x)?;
135196×
                if best.as_ref().is_none_or(|(t, _, _)| c.hlc > *t) {
136178×
                    best = Some((c.hlc, *x, c.message.clone()));
137178×
                }
138
            }
139151×
            Ok(best)
140151×
        })
141151×
        .await
142151×
        .ok()??;
143151×
    skip_marker(&newest.2).map(|m| (newest.1, m))
144152×
}
145
146
/// Record a push's run as skipped (#284): finished, with no jobs, saying
147
/// which change asked and who pushed it. The next run's scope still covers
148
/// what this push brought, as a skipped run is never its baseline.
149
#[allow(clippy::too_many_arguments)]
1501×
async fn skip_run(
1511×
    app: &Shared,
1521×
    row: &db::repos::RepoRow,
1531×
    bookmark: &str,
1541×
    state: &str,
1551×
    heads: &[ChangeHash],
1561×
    actor: &str,
1571×
    change: &ChangeHash,
1581×
    marker: &str,
1591×
) {
1601×
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
1611×
    hex.sort();
1621×
    let heads_s = hex.join("+");
1631×
    let why = format!("{marker} in change {}, pushed by {actor}", change.short(12));
1641×
    let base =
1651×
        db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger: "push", error: None };
1661×
    if let Err(e) = db::flow::create_skipped_run(&app.db, base, &why).await {
1670×
        tracing::warn!("flow: recording a skipped run of {}/{}: {e}", row.owner, row.name);
1681×
    }
1691×
}
170
171
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
172
/// moving it. Returns the new run's number.
173
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
174
/// the jobs they depend on.
17511×
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
17611×
    if !enabled(app, row) {
1771×
        return Err("OxygenFlow is not enabled for this repository".into());
17810×
    }
17910×
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
18010×
    let b = bookmark.to_string();
18110×
    let found = app
18210×
        .repos
18310×
        .read(&repo, move |r, _| {
18410×
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
1859×
            Ok(Some((st, r.store().get_state(&st)?.heads)))
18610×
        })
18710×
        .await
18810×
        .map_err(|e| e.to_string())?;
18910×
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
1909×
    if heads.is_empty() {
1910×
        return Err(format!("{bookmark} has no changes yet"));
1929×
    }
1939×
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
1949×
        .await?
1958×
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
19611×
}
197
198
/// "Run pipeline" on one committed change: the state whose only head is
199
/// `change`, whatever bookmarks hold it. Returns the new run's number.
2002×
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
2012×
    if !enabled(app, row) {
2020×
        return Err("OxygenFlow is not enabled for this repository".into());
2032×
    }
2042×
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
2052×
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2062×
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
2072×
    if !known {
2081×
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
2091×
    }
2101×
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
2111×
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
2121×
        .await?
2131×
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
2142×
}
215
216
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
217
/// run whose checks replace the old ones. Returns the new run's number.
2183×
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
2193×
    if !enabled(app, row) {
2200×
        return Err("OxygenFlow is not enabled for this repository".into());
2213×
    }
2223×
    if run.live() {
2231×
        return Err(format!("run #{} is still {}", run.number, run.status));
2242×
    }
2252×
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
2262×
    if heads.is_empty() {
2270×
        return Err("the run names no changes".into());
2282×
    }
229
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
2302×
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
2312×
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
2322×
        .await?
2332×
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
2343×
}
235
236
/// Run one job of a finished run again (oxygenflow#31), with the jobs
237
/// that depend on it; the jobs it depends on keep their results, so a
238
/// flaky test reruns without the build before it. Its checks go back to
239
/// queued. Returns how many jobs were queued again.
2404×
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
2414×
    if !enabled(app, row) {
2420×
        return Err("OxygenFlow is not enabled for this repository".into());
2434×
    }
2444×
    if run.live() {
2452×
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
2462×
    }
2472×
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
2480×
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
2490×
        "the job could not be queued again".to_string()
2500×
    })?;
2512×
    if changed.is_empty() {
2520×
        return Err(format!("run #{} has no job {job:?}", run.number));
2532×
    }
2543×
    for j in &changed {
2553×
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
256
    }
2572×
    Ok(changed.len())
2584×
}
259
260
/// Cancel a run still going (oxygenflow#32), its checks with it.
2612×
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
2622×
    if !run.live() {
2631×
        return Err(format!("run #{} is already {}", run.number, run.status));
2641×
    }
2651×
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
2660×
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
2670×
        "the run could not be cancelled".to_string()
2680×
    })
2692×
}
270
27117×
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
27217×
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
27317×
    report_changed(app, run.id, changed).await
27417×
}
275
276
/// Why job `job` of `run` cannot be decided on by `who` (oxygenflow#57),
277
/// or its approval: it must be waiting, the jobs it depends on done and
278
/// passed, `who` must have the role it names on the repository, and with
279
/// others-only must not be the person whose push started the run.
28022×
pub async fn may_decide(
28122×
    app: &Shared,
28222×
    row: &db::repos::RepoRow,
28322×
    run: &fdb::Run,
28422×
    job: &fdb::Job,
28522×
    who: &db::accounts::Account,
28622×
) -> Result<flow_core::Approval, String> {
28722×
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
28822×
    let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) };
28922×
    if job.status != "queued" || job.approval != "waiting" {
2900×
        return Err(format!("{} is not waiting for approval", job.name));
29122×
    }
29222×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
29325×
    if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) {
2941×
        return Err(format!("{} waits for {d} first", job.name));
29521×
    }
29621×
    let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain);
29721×
    let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?;
29821×
    if role.is_none_or(|r| r < need) {
2995×
        return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name));
30016×
    }
30116×
    if approval.others_only && who.username == run.actor {
3024×
        return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor));
30312×
    }
30412×
    Ok(approval)
30522×
}
306
307
/// Approve or reject job `name` of `run` as `who` (oxygenflow#57): the
308
/// decision goes on the job, its log and the audit log; a rejected job ends
309
/// as cancelled and the jobs after it are skipped. What happened, or why not.
3107×
pub async fn decide(
3117×
    app: &Shared,
3127×
    row: &db::repos::RepoRow,
3137×
    run: &fdb::Run,
3147×
    name: &str,
3157×
    who: &db::accounts::Account,
3167×
    approve: bool,
3177×
    via: Option<&str>,
3187×
) -> Result<String, String> {
3197×
    decide_with(app, row, run, name, who, approve, Through { seal: None, via }).await
3207×
}
321
322
/// How a decision arrived: with a sealed approval's ref, or through an
323
/// agent token (by its name).
324
#[derive(Clone, Copy, Default)]
325
struct Through<'a> {
326
    seal: Option<&'a str>,
327
    via: Option<&'a str>,
328
}
329
330
/// [`decide`], from a verified seal (`Some(approval ref)`) or without one;
331
/// a gate that takes a sealed approval is not approved without.
3328×
async fn decide_with(
3338×
    app: &Shared,
3348×
    row: &db::repos::RepoRow,
3358×
    run: &fdb::Run,
3368×
    name: &str,
3378×
    who: &db::accounts::Account,
3388×
    approve: bool,
3398×
    through: Through<'_>,
3408×
) -> Result<String, String> {
3418×
    let Through { seal, via } = through;
3428×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
34316×
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
3448×
    let approval = may_decide(app, row, run, &job, who).await?;
345
    // A gate exists so a person decides (#258): an agent's token decides it
346
    // only where the repository allows it. A sealed decision is a person's
347
    // key, whatever carried it.
3485×
    if let (Some(agent), None) = (via, seal) {
3492×
        if !row.agents_decide_gates {
3501×
            return Err(format!(
3511×
                "{name} is decided by a person here, not through an agent's token ({agent}): decide it yourself on the run page, or a maintainer can let agents decide approval gates in Settings › OxygenFlow"
3521×
            ));
3531×
        }
3543×
    }
3554×
    if approve && approval.sealed && seal.is_none() {
3561×
        return Err(format!(
3571×
            "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}",
3581×
            row.owner,
3591×
            row.name,
3601×
            app.config.base_url(),
3611×
            row.owner,
3621×
            row.name,
3631×
            run.number
3641×
        ));
3653×
    }
3663×
    let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number);
3673×
    let sealed = seal.map(|r| format!(", sealed (approval {})", &r[..12.min(r.len())])).unwrap_or_default();
368
    // Through an agent's token, it says so (oxygenhub#229).
3693×
    let by = match via {
3701×
        Some(v) => format!("{} via {v}", who.username),
3712×
        None => who.username.clone(),
372
    };
3733×
    let detail = [seal.unwrap_or(""), &via.map(|v| format!("via {v}")).unwrap_or_default()]
3743×
        .iter()
3756×
        .filter(|s| !s.is_empty())
3763×
        .cloned()
3773×
        .collect::<Vec<_>>()
3783×
        .join(", ");
3793×
    let said = if approve {
3802×
        if !fdb::approve_job(&app.db, job.id, &who.username, seal.unwrap_or(""), via.unwrap_or("")).await.map_err(|e| e.to_string())? {
3810×
            return Err(format!("{name} was decided on meanwhile"));
3822×
        }
3832×
        db::accounts::audit(&app.db, Some(who), "flow.approved", &target, &detail).await.ok();
3842×
        format!("approved by {by}{sealed}")
385
    } else {
3861×
        fdb::note_rejection(&app.db, job.id, &who.username, via.unwrap_or("")).await.map_err(|e| e.to_string())?;
3871×
        let why = format!("rejected by {by}{sealed}");
3881×
        let changed =
3891×
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
3901×
                .await
3911×
                .map_err(|e| e.to_string())?;
3921×
        report_changed(app, run.id, changed).await.ok();
3931×
        db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, &detail).await.ok();
3941×
        why
395
    };
3963×
    let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() };
3973×
    fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
3983×
    Ok(format!("{name}: {said}"))
3998×
}
400
401
/// Tell the people who may approve a job of `run` that has just become
402
/// ready (its dependencies all passed) and waits for them (oxygenflow#57).
403
/// Told once: the job's log notes it.
40493×
async fn tell_approvers(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
405
    const TOLD: &str = "waiting for approval; the people who may approve were told";
40693×
    let Ok(jobs) = fdb::jobs(&app.db, run.id).await else { return };
407261×
    for job in jobs.iter().filter(|j| j.status == "queued" && j.approval == "waiting") {
4087×
        if !job.depends_on().iter().all(|d| jobs.iter().any(|j| &j.name == d && j.status == "passed")) {
4090×
            continue;
4105×
        }
4115×
        let already = fdb::log(&app.db, job.id, 0, 50).await.unwrap_or_default().iter().any(|l| l.text == TOLD);
4125×
        if already {
4130×
            continue;
4145×
        }
4155×
        let mut targets = Vec::new();
41613×
        for a in db::accounts::accounts(&app.db).await.unwrap_or_default().iter().filter(|a| !a.blocked()) {
41713×
            if may_decide(app, row, run, job, a).await.is_ok() {
4186×
                targets.push((a.id, db::notify::Reason::Participating));
4197×
            }
420
        }
4215×
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: TOLD.into() };
4225×
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
4235×
        let title = format!("{}/{} run #{}: {} waits for your approval", row.owner, row.name, run.number, job.name);
4245×
        let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number);
4255×
        crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await;
426
    }
42793×
}
428
429
/// What approving job `name` of `run` means, for the approver to sign
430
/// (oxygenhub#235): this repository, the run's URL, the job's hash with its
431
/// sandbox, the run's state, the blobs it takes, its environment.
4325×
pub async fn approval_statement(
4335×
    app: &Shared,
4345×
    row: &db::repos::RepoRow,
4355×
    run: &fdb::Run,
4365×
    name: &str,
4375×
) -> Result<(oxy_core::approval::Statement, fdb::Job), String> {
4385×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
43910×
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
4405×
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
4415×
    let sandbox: flow_core::Sandbox = serde_json::from_str(&job.sandbox).map_err(|e| format!("stored sandbox: {e}"))?;
4425×
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
4435×
    let repo = app.repos.read(&repo_name, |r, _| Ok(r.id()?)).await.map_err(|e| e.to_string())?;
4445×
    let state = oxy_core::hash::ObjectRef::from_hex(&run.state).ok_or("the run has no state")?;
4455×
    let inputs = artifact_inputs(app, &job, &spec).await?.iter().filter_map(|i| oxy_core::hash::ObjectRef::from_hex(&i.blob)).collect();
4465×
    let statement = oxy_core::approval::Statement {
4475×
        repo,
4485×
        run: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, run.number),
4495×
        job: name.to_string(),
4505×
        job_hash: oxy_core::hash::ObjectRef(spec.hash(&sandbox)),
4515×
        state,
4525×
        inputs,
4535×
        environment: spec.environment.clone().unwrap_or_default(),
4545×
        approve: true,
4555×
    };
4565×
    Ok((statement, job))
4575×
}
458
459
/// An unlinked key's account, when its identity succeeds a linked key's
460
/// through `previous` links that each keep the key pair (a renewal or a
461
/// delegation, Oxygen spec §14.2), with the identities from it down to the
462
/// linked one, to link (oxygenhub#236). A step that changes the key pair
463
/// ends the search: that is a new key, linked by proving it.
4643×
async fn linked_through_chain(
4653×
    app: &Shared,
4663×
    repo: &RepoName,
4673×
    signer: &oxy_core::identity::Identity,
4683×
    links: &[(String, String)],
4693×
) -> Option<(String, Vec<oxy_core::identity::Identity>)> {
4703×
    let mut chain = vec![signer.clone()];
4713×
    let mut cur = signer.clone();
4723×
    for _ in 0..64 {
4733×
        let prev = cur.previous?;
4742×
        let hex = prev.to_hex();
4752×
        let found = match db::accounts::identity_by_key(&app.db, &hex).await.ok()? {
4762×
            Some(l) => oxy_core::identity::Identity::decode(&l.object).ok(),
4770×
            None => app.repos.read(repo, move |r, _| Ok(r.store().get_identity(&prev).ok())).await.ok().flatten(),
478
        };
4792×
        let id = found?;
4802×
        if id.hash() != prev || id.ed25519_pk != cur.ed25519_pk || id.mldsa65_pk != cur.mldsa65_pk {
4811×
            return None;
4821×
        }
4831×
        if let Some((_, u)) = links.iter().find(|(k, _)| *k == hex) {
4841×
            return Some((u.clone(), chain));
4850×
        }
4860×
        chain.push(id.clone());
4870×
        cur = id;
488
    }
4890×
    None
4903×
}
491
492
/// Decide on job `name` of `run` from a signed approval (oxygenhub#235): it
493
/// must verify, cover exactly this job's statement, and be signed by a key
494
/// linked to an account (a delegated agent key is not) that may decide; it
495
/// is kept in the repository. What happened, or why not.
4964×
pub async fn decide_sealed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, name: &str, signed_hex: &str) -> Result<String, String> {
4974×
    let bytes = hex::decode(signed_hex.trim()).map_err(|_| "the signed approval is not hex".to_string())?;
4984×
    let sa = oxy_core::approval::SignedApproval::decode(&bytes).map_err(|e| format!("not a signed approval: {e}"))?;
4994×
    sa.verify().map_err(|e| format!("it does not verify: {e}"))?;
5004×
    let (mut expected, _) = approval_statement(app, row, run, name).await?;
5014×
    let given = sa.statement().map_err(|e| e.to_string())?;
5024×
    expected.approve = given.approve;
5034×
    if !sa.covers(&expected) {
5041×
        return Err(format!(
5051×
            "it approves something else than job {name} of run #{} as it is now (another run, state, plan or environment): sign again",
5061×
            run.number
5071×
        ));
5083×
    }
5093×
    let key = sa.signer.hash().to_hex();
5103×
    let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?;
5113×
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
5123×
    let username = match links.iter().find(|(k, _)| *k == key) {
5130×
        Some((_, u)) => u.clone(),
514
        // A renewal or delegation of a linked key keeps its key pair: it is
515
        // the same person's, and linked now (oxygenhub#236).
5163×
        None => match linked_through_chain(app, &repo_name, &sa.signer, &links).await {
5171×
            Some((u, chain)) => {
5181×
                if let Ok(Some(acc)) = db::accounts::account_by_name(&app.db, &u).await {
5191×
                    for id in &chain {
5201×
                        let k = id.hash().to_hex();
5211×
                        if let Ok(None) = db::accounts::identity_by_key(&app.db, &k).await {
5221×
                            db::accounts::link_identity(&app.db, acc.id, &k, &id.name, &id.email, &id.encode()).await.ok();
5230×
                        }
524
                    }
5250×
                }
5261×
                u
527
            }
528
            None => {
5292×
                return Err(format!(
5302×
                    "key {} is not linked to an account here, nor a renewal of a key that is (link it under Settings › Keys; an agent's delegated key cannot approve)",
5312×
                    &key[..12]
5322×
                ))
533
            }
534
        },
535
    };
5361×
    let who = db::accounts::account_by_name(&app.db, &username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?;
5371×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
5382×
    let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?;
5391×
    may_decide(app, row, run, job, &who).await?;
5401×
    let (rid, signers) = (sa.attest.repo, sa.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
5411×
    let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?;
5421×
    crate::tlog::log(app, vec![crate::tlog::Pending { kind: "approval", repo: rid, repo_row: Some(row.id), object: kept, signers }]).await;
5431×
    decide_with(app, row, run, name, &who, given.approve, Through { seal: Some(&kept.to_hex()), via: None }).await
5444×
}
545
546
/// End the approvals that waited too long (oxygenflow#57): from when the
547
/// jobs a waiting job depends on finished (or its run began).
5482×
pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> {
5492×
    let mut ended = 0;
5502×
    for job in fdb::waiting_for_approval(&app.db).await? {
5512×
        let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue };
5522×
        let Some(approval) = spec.approval else { continue };
5532×
        let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue };
5542×
        let jobs = fdb::jobs(&app.db, run.id).await?;
5552×
        let wanted = job.depends_on();
5564×
        let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect();
5572×
        if deps.iter().any(|d| d.status != "passed") {
5580×
            continue;
5592×
        }
5602×
        let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms);
5612×
        if now - since < approval.expires_s as i64 * 1000 {
5621×
            continue;
5631×
        }
5641×
        let hours = approval.expires_s as f64 / 3600.0;
5651×
        let why =
5661×
            format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) });
5671×
        let changed =
5681×
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
5691×
                .await?;
5701×
        report_changed(app, run.id, changed).await?;
5711×
        ended += 1;
572
    }
5732×
    Ok(ended)
5742×
}
575
576
/// Newest run wins (oxygenhub#267): whether a new run started by `trigger`
577
/// cancels the older runs of its bookmark still live.
578
///
579
/// - A **push** run supersedes the older push runs of its bookmark: they
580
///   test states the bookmark has moved past. Diffusion's bumps start push
581
///   runs too.
582
/// - A **proposal** run (bookmark `proposal/N`, started when the
583
///   proposal's target moved, `web::proposals::settle_after_push`)
584
///   supersedes the older proposal runs of the same proposal: they test
585
///   projected states it no longer has. It leaves the proposal's source
586
///   bookmark's push runs alone: those test the source head, a different
587
///   state that other proposals or the bookmark's own checks may want.
588
/// - **Tag**, **manual** ("Run pipeline" on a bookmark or a change),
589
///   **schedule** and **rerun** runs were each asked for in their own
590
///   right (a person or a cron), so they supersede nothing and nothing
591
///   supersedes them: a rerun of an old push run tests the state it names,
592
///   on purpose. They are cancelled by hand.
593
///
594
/// Only runs of the same trigger on the same bookmark are superseded, so a
595
/// push never cancels a rerun or a proposal's run, nor the other way round.
596139×
fn supersedes(trigger: &str) -> bool {
597139×
    matches!(trigger, "push" | "proposal")
598139×
}
599
600
/// Cancel what run `number` supersedes ([`supersedes`]): the older runs of
601
/// `bookmark` started by the same `trigger` still queued, and those already
602
/// running unless the repository keeps them (`flow_keep_running`, Settings
603
/// › OxygenFlow). A push run's scope reaches back past them either way
604
/// ([`fdb::latest_on`]).
605124×
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) {
606124×
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return };
607124×
    for run in older {
60811×
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
6090×
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
61011×
        }
611
    }
612124×
}
613
614
/// A proposal landed (#263): its runs that no runner has started can no
615
/// longer change anything, and they hold the queue ahead of the target's
616
/// own run, so they are cancelled. Its source bookmark's push runs (when
617
/// that bookmark is in this repository and is not the target) and its
618
/// `proposal/N` runs; running ones are left to finish.
61922×
pub async fn moot_after_landing(app: &Shared, row: &db::repos::RepoRow, p: &db::proposals::ProposalRow) {
62022×
    let now = db::now_ms();
62122×
    let mut moot = Vec::new();
62222×
    let same_repo = p.source_repo_id.is_none_or(|id| id == row.id);
62322×
    if p.source_kind == "bookmark" && same_repo && !p.source_bookmark.is_empty() && p.source_bookmark != p.target {
62422×
        moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &p.source_bookmark, "push", now).await.unwrap_or_default());
6250×
    }
62622×
    moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &format!("proposal/{}", p.number), "proposal", now).await.unwrap_or_default());
62722×
    for run in moot {
6285×
        if let Err(e) = cancel_with(app, &run, &format!("moot: proposal #{} landed", p.number)).await {
6290×
            tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
6305×
        }
631
    }
63222×
}
633
634
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
635
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
636142×
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
637
    use flow_core::pipeline::Event;
638142×
    let t = match trigger.strip_prefix("rerun:") {
6393×
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
640139×
        None => trigger,
641
    };
642142×
    if let Some(tag) = t.strip_prefix("tag:") {
6435×
        Event::Tag(tag)
644137×
    } else if t == "push" {
645119×
        Event::Push(bookmark)
64618×
    } else if t == "schedule" {
6470×
        Event::Schedule
648
    } else {
64918×
        Event::Manual
650
    }
651142×
}
652
653
/// The event to repeat when rerunning `run`.
6542×
fn original_event(run: &fdb::Run) -> String {
6552×
    let t = run.trigger.as_str();
6562×
    match t.strip_prefix("rerun:") {
6570×
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
6582×
        None => t.to_string(),
659
    }
6602×
}
661
662
/// The paths the changes a push brought touch: the changes in `heads`'
663
/// state that the bookmark's previous run did not have, and the files their
664
/// edits and tree operations name, as the new state places them. `None`
665
/// (run every job) for the bookmark's first run, or when it cannot be told.
666151×
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
667151×
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
66828×
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
66928×
    if old.is_empty() {
6700×
        return None;
67128×
    }
67228×
    let repo = RepoName::new(&row.owner, &row.name)?;
67328×
    let new = heads.to_vec();
67428×
    app.repos
67528×
        .read(&repo, move |r, cache| {
67628×
            let fresh = r.negotiate(&old, &new)?;
67728×
            let loaded = cache.load(r, &new)?;
67828×
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
679140×
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
68028×
            let mut paths = std::collections::BTreeSet::new();
68131×
            for h in fresh {
68231×
                let (c, _) = r.store().get_change_lazy(&h)?;
68345×
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
68457×
                for n in nodes {
68557×
                    if let Some(p) = by_node.get(&n) {
68657×
                        paths.insert((*p).clone());
68757×
                    }
688
                }
689
            }
69028×
            Ok(paths.into_iter().collect::<Vec<String>>())
69128×
        })
69228×
        .await
69328×
        .ok()
694151×
}
695
696
/// Whether a job runs for a push that touched `touched`: always without a
697
/// scope or without knowing what was touched, else when a path matches.
698381×
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
699381×
    let Some(touched) = touched else { return true };
70071×
    if scope.is_empty() {
70165×
        return true;
7026×
    }
7036×
    let mut b = globset::GlobSetBuilder::new();
7048×
    for g in scope {
7058×
        match globset::Glob::new(g) {
7068×
            Ok(g) => {
7078×
                b.add(g);
7088×
            }
7090×
            Err(_) => return true,
710
        }
711
    }
7126×
    let Ok(set) = b.build() else { return true };
7136×
    touched.iter().any(|p| set.is_match(p))
714381×
}
715
716
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
717
/// left out of the run, unless a job that runs depends on them.
718
#[allow(clippy::too_many_arguments)]
719163×
pub async fn start_run_scoped(
720163×
    app: &Shared,
721163×
    row: &db::repos::RepoRow,
722163×
    bookmark: &str,
723163×
    state: &str,
724163×
    heads: &[ChangeHash],
725163×
    actor: &str,
726163×
    trigger: &str,
727163×
    touched: Option<&[String]>,
728163×
) -> Result<Option<i64>, String> {
729163×
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
730161×
}
731
732
/// Start a run of the pipeline in the state `heads` make (whose state
733
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
734
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
735
/// whose access the run's subrepo reads are checked against. Returns the
736
/// run's number, or `None` when the state has no pipeline. The caller
737
/// checks that Flow is enabled.
7382×
pub async fn start_run(
7392×
    app: &Shared,
7402×
    row: &db::repos::RepoRow,
7412×
    bookmark: &str,
7422×
    state: &str,
7432×
    heads: &[ChangeHash],
7442×
    actor: &str,
7452×
    trigger: &str,
7462×
) -> Result<Option<i64>, String> {
7472×
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
7482×
}
749
750
#[allow(clippy::too_many_arguments)]
751171×
async fn start_run_with(
752171×
    app: &Shared,
753171×
    row: &db::repos::RepoRow,
754171×
    bookmark: &str,
755171×
    state: &str,
756171×
    heads: &[ChangeHash],
757171×
    actor: &str,
758171×
    trigger: &str,
759171×
    touched: Option<&[String]>,
760171×
) -> Result<Option<i64>, String> {
761171×
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
762169×
}
763
764
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
765
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
766
#[allow(clippy::too_many_arguments)]
767185×
async fn start_run_at(
768185×
    app: &Shared,
769185×
    row: &db::repos::RepoRow,
770185×
    bookmark: &str,
771185×
    state: &str,
772185×
    heads: &[ChangeHash],
773185×
    actor: &str,
774185×
    trigger: &str,
775185×
    touched: Option<&[String]>,
776185×
    at: Option<flow_core::pipeline::When>,
777185×
    only: &[String],
778185×
) -> Result<Option<i64>, String> {
779185×
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
780185×
    let h = heads.to_vec();
781185×
    let text = app
782185×
        .repos
783185×
        .read(&repo, move |r, cache| {
784185×
            let loaded = cache.load(r, &h)?;
785185×
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
786149×
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
78736×
                _ => None,
788
            })
789185×
        })
790185×
        .await
791185×
        .map_err(|e| format!("reading the pipeline: {e}"))?;
792185×
    let Some(text) = text else { return Ok(None) };
793185×
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
794149×
    hex.sort();
795149×
    let heads_s = hex.join("+");
796149×
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
797149×
    let id = match Pipeline::parse(&text) {
798
        // A scheduled tick cannot tell whether a pipeline it cannot read
799
        // has a schedule, so it starts nothing (#321): the push's run
800
        // already says what is wrong, once rather than every minute.
8015×
        Err(_) if at.is_some() => return Ok(None),
8023×
        Err(problems) => {
8033×
            let msg = problems.to_string();
8043×
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
805
        }
806144×
        Ok(p) => {
807144×
            let depths = p.depths();
808
            // Jobs out of the push's scope stay out, unless a kept job
809
            // needs them.
810
            // The jobs this run's event starts (`on`), out of those its
811
            // push touched (`scope`); then what they depend on.
812144×
            let event = match at {
8132×
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
814142×
                None => event_of(trigger, bookmark),
815
            };
816403×
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
817144×
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
818397×
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
819
            } else {
820
                // Named jobs (oxygenhub#130): each one this event starts.
8216×
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
8222×
                for name in only {
8232×
                    if !startable.contains(&name.as_str()) {
8241×
                        let what = if p.jobs.contains_key(name) {
8250×
                            "is not started by hand (no `on { manual }`)"
826
                        } else {
8271×
                            "is not in the pipeline"
828
                        };
8291×
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
8301×
                    }
831
                }
8321×
                only.iter().cloned().collect()
833
            };
834
            loop {
835144×
                let needed: Vec<String> = p
836144×
                    .order()
837144×
                    .into_iter()
838403×
                    .filter(|j| keep.contains(&j.name))
839376×
                    .flat_map(|j| j.depends_on.iter().cloned())
840144×
                    .filter(|d| !keep.contains(d))
841144×
                    .collect();
842144×
                if needed.is_empty() {
843143×
                    break;
8441×
                }
8451×
                keep.extend(needed);
846
            }
847143×
            let mut jobs: Vec<fdb::NewJob> = p
848143×
                .order()
849143×
                .into_iter()
850400×
                .filter(|j| keep.contains(&j.name))
851375×
                .map(|j| {
852375×
                    let sb = &p.sandboxes[&j.sandbox];
853375×
                    fdb::NewJob {
854375×
                        name: j.name.clone(),
855375×
                        depth: depths[j.name.as_str()] as i64,
856375×
                        depends: j.depends_on.clone(),
857375×
                        spec: serde_json::to_string(j).unwrap_or_default(),
858375×
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
859375×
                        runtime: sb.runtime.as_str().into(),
860375×
                        need_disk_mib: None,
861375×
                        reused: None,
862375×
                    }
863375×
                })
864143×
                .collect();
865
            // What each job needs, learned from its past runs (oxygenflow#41).
866375×
            for j in &mut jobs {
867375×
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
868
            }
869143×
            if jobs.is_empty() {
870
                // Nothing this event starts (or this push touched).
8715×
                return Ok(None);
872138×
            }
873
            // A job that passed on this very state, with the same spec and
874
            // sandbox, is not run again by a push (#265): main after a
875
            // proposal landed it as tested. Never one that publishes, deploys
876
            // or holds secrets, nor one whose artifacts a job of this run
877
            // takes; and not where the repository turned reuse off.
878138×
            if trigger == "push" && row.flow_reuse {
879115×
                let consumed: std::collections::HashSet<&str> = p
880115×
                    .order()
881115×
                    .into_iter()
882320×
                    .filter(|j| keep.contains(&j.name))
883316×
                    .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str()))
884115×
                    .collect();
885316×
                for j in &mut jobs {
886316×
                    let Some(spec) = p.jobs.get(&j.name) else { continue };
887316×
                    if !reusable(spec) || consumed.contains(j.name.as_str()) {
88810×
                        continue;
889306×
                    }
890306×
                    j.reused =
891306×
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
892
                    // A fork starts green where upstream proved this state, and
893
                    // any repository where another on the hub did (#296).
894306×
                    if j.reused.is_none() && row.forked_from.is_some() {
8956×
                        j.reused =
8966×
                            fdb::passed_upstream(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
897300×
                    }
898306×
                    if j.reused.is_none() {
899291×
                        j.reused =
900291×
                            fdb::passed_elsewhere(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
90115×
                    }
902
                    // Another repository's pass is taken only on its signed
903
                    // receipt, checked again now: otherwise it runs here.
904306×
                    if let Some((from, attest)) = j.reused.as_ref().and_then(|r| Some((r.repo.clone()?, r.receipt_attest.clone()))) {
9059×
                        if let Err(why) = receipt_holds(app, &from, &attest).await {
9061×
                            tracing::info!("flow: not reusing {from}'s pass of {} on {state}: {why}", j.name);
9071×
                            j.reused = None;
9088×
                        }
909297×
                    }
910
                }
91123×
            }
912138×
            let hash = hex::encode(p.hash());
913138×
            let id =
914138×
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
915138×
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
916372×
                for j in jobs.iter().filter(|j| j.reused.is_none()) {
917355×
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
918
                }
919
                // Reused passes (#265) report as any pass does: their checks,
920
                // and a run that passed with them (Diffusion, landing a bump).
921356×
                if jobs.iter().any(|j| j.reused.is_some()) {
9226×
                    let reused: Vec<fdb::Job> =
92319×
                        fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect();
9246×
                    if let Err(e) = report_changed(app, run.id, reused).await {
9250×
                        tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number);
9266×
                    }
927130×
                }
928136×
                images_check(app, row, state, run.number, &jobs).await;
9290×
            }
930136×
            id
931
        }
932
    };
933139×
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
934139×
    if supersedes(trigger) {
935124×
        supersede(app, row, bookmark, trigger, run.number).await;
93615×
    }
937139×
    Ok(Some(run.number))
938183×
}
939
940
/// The `pipeline/images` check (#137): set failing when an image the run's
941
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
942
/// one says so before it lands rather than its runs queueing for good;
943
/// passed again by a later run of the state once every image is held.
944136×
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
945136×
    let mut pinned: Vec<String> = jobs
946136×
        .iter()
947369×
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
948369×
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
949369×
        .filter(|r| !r.is_empty())
950136×
        .collect();
951136×
    pinned.sort();
952136×
    pinned.dedup();
953136×
    if pinned.is_empty() {
9541×
        return;
955135×
    }
956135×
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
957
    // Nobody can be said to lack an image unless some runner is known and
958
    // every one reports what it holds.
959135×
    let runners: Vec<db::runners::Runner> =
960135×
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
961135×
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
962129×
        return;
9636×
    }
9646×
    for r in &runners {
9656×
        held.extend(r.image_list().unwrap_or_default());
9666×
    }
9676×
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
968
    // Only a missing image makes the check; one that was failing on this
969
    // state turns to passed once its images are held.
9706×
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
9716×
        .await
9726×
        .unwrap_or_default()
9736×
        .iter()
97413×
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
9756×
    if missing.is_empty() && !failing_before {
9763×
        return;
9773×
    }
9783×
    let (status, summary) = if missing.is_empty() {
979
        (
980
            "passed",
9811×
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
982
        )
983
    } else {
9842×
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
9852×
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
986
    };
9873×
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
9883×
    if let Ok(c) = db::hooks::upsert_check(
9893×
        &app.db,
9903×
        row.id,
9913×
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
992
    )
9933×
    .await
994
    {
9953×
        let api = crate::api::check_of(&c);
9963×
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
9973×
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
9980×
    }
999136×
}
1000
1001
/// Record a job's check; it links to the run's page by the run's number.
1002710×
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
1003710×
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
1004710×
    let name = format!("flow/{job}");
1005710×
    let c = db::hooks::upsert_check(
1006710×
        &app.db,
1007710×
        row.id,
1008710×
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
1009710×
    )
1010710×
    .await;
1011709×
    if let Ok(c) = c {
1012709×
        let api = crate::api::check_of(&c);
1013709×
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1014709×
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
10150×
    }
1016708×
}
1017
1018
/// The check status a job's status shows as.
1019174×
fn check_status(job_status: &str) -> &'static str {
1020174×
    match job_status {
1021174×
        "queued" => "queued",
1022174×
        "running" => "running",
1023174×
        "passed" => "passed",
102470×
        "skipped" => "skipped",
102562×
        "cancelled" => "cancelled",
102617×
        _ => "failed",
1027
    }
1028174×
}
1029
1030
/// The runner a bearer token belongs to: a registered runner, or one
1031
/// configured with a hub.toml token (tracked from its first request). A
1032
/// disabled runner is refused.
1033423×
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
1034423×
    let Some(t) = oxy_server::bearer_token(headers) else {
10354×
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
1036
    };
1037419×
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
1038419×
    let runner = match db::runners::by_token(&app.db, &hash).await? {
1039351×
        Some(r) => r,
104087×
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
104163×
            db::runners::configured(&app.db, &hash, "runner").await?
1042
        }
10435×
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
1044
    };
1045414×
    if runner.state == "disabled" {
10461×
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
1047413×
    }
1048413×
    Ok(runner)
1049423×
}
1050
1051
/// `POST /api/v1/flow/runners/register`: a one-time registration token
1052
/// from Admin → Runners buys a runner credential, binding the runner's
1053
/// signing identity (hub spec §13.2).
105418×
pub async fn register(
105518×
    State(app): State<Shared>,
105618×
    Json(req): Json<flow_core::api::Register>,
105718×
) -> Result<Json<flow_core::api::Registered>, ApiError> {
105818×
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
105918×
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
1060108×
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
106118×
    if name.is_empty() {
10620×
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
106318×
    }
106418×
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
106518×
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
106618×
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
106717×
    let runner =
106818×
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
10691×
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
10701×
        })?;
107117×
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
107217×
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
107317×
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
107418×
}
1075
1076
/// `POST /api/v1/flow/claim`.
1077233×
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
1078233×
    let me = runner_auth(&app, &headers).await?;
10791283×
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
1080229×
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
1081229×
    if let Some(holding) = &req.holding {
10822×
        end_jobs_not_held(&app, &me, holding).await?;
1083227×
    }
1084
    // A draining runner finishes what it has and takes nothing new.
1085229×
    if me.state == "draining" {
10861×
        return Ok(StatusCode::NO_CONTENT.into_response());
1087228×
    }
1088228×
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
1089228×
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
1090
    // What it holds and what it is (OxygenFlow spec §11): the images'
1091
    // architectures it reports are remembered for every runner's claims.
1092228×
    if let Some(archs) = &req.image_archs {
10935×
        fdb::record_image_archs(&app.db, archs).await?;
1094223×
    }
1095228×
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
1096
    // What it offers, kept for the placement pass; a job it declined is
1097
    // not offered to it again while this stays the same (spec §11.5).
1098228×
    let profile = profile_of(&req, &arch);
1099228×
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
1100228×
    if let Some(f) = &req.facts {
110113×
        db::runners::set_facts(&app.db, me.id, f).await?;
1102215×
    }
1103228×
    app.swarm.claimed(me.id, req.swarm.as_ref());
1104
    // The build caches it holds (oxygenflow#51), and when each last kept a
1105
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
1106228×
    if let Some(c) = &req.caches {
11076×
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
11086×
        CACHES
11096×
            .lock()
11106×
            .unwrap_or_else(|p| p.into_inner())
11116×
            .get_or_insert_with(Default::default)
11126×
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
1113222×
    }
1114228×
    let room = fdb::Room {
1115228×
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
1116228×
        memory_mib: req.free_memory_mib,
1117228×
        cpus: req.free_cpus,
1118228×
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
1119228×
        arch,
1120228×
        image_archs: fdb::image_archs(&app.db).await?,
1121
        // The hub's own images, and those a runner of its site holds (oxygenflow#94).
1122228×
        fetchable: if req.fetches_images { crate::images::reach(&app).await?.for_runner(me.id) } else { Default::default() },
1123228×
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
1124
    };
1125
    // The labels it reports now, or those it registered with, and those
1126
    // an administrator gave it (#98).
1127228×
    let mut labels: Vec<String> = match &req.info {
112813×
        Some(i) => i.labels.clone(),
1129215×
        None => me.reported_label_list().into_iter().map(String::from).collect(),
1130
    };
1131228×
    for l in me.admin_label_list() {
11320×
        if !labels.iter().any(|x| x == l) {
11330×
            labels.push(l.to_string());
11340×
        }
1135
    }
1136
    // Ranking (oxygenflow#42): a job another free runner would do better
1137
    // (it holds the image, or is faster) is held for it briefly.
1138228×
    let runners = db::runners::list(&app.db).await?;
1139259×
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
1140228×
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
1141228×
    let queue = fdb::queue(&app.db).await?;
1142461×
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
1143
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
1144228×
    let slots: std::collections::HashMap<i64, String> =
1145461×
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
1146228×
    let (archs, reach) = (fdb::image_archs(&app.db).await?, crate::images::reach(&app).await?);
1147228×
    let now = db::now_ms();
1148228×
    let fleet = Fleet { image_archs: &archs, reach: &reach, now };
1149228×
    let hub_key = app.repos.root().display().to_string();
1150228×
    let better = |j: &fdb::Job| -> bool {
1151162×
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
1152162×
        let rootfs = rootfs_of(j);
1153162×
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
1154162×
        let holds = holds.get_or_insert_with(Default::default);
1155162×
        holds.retain(|_, t| now - *t < 600_000);
1156162×
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
11570×
            return false;
1158162×
        }
1159162×
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
1160162×
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
1161188×
        let other = runners.iter().any(|r| {
1162188×
            r.id != me.id
116329×
                && r.state == "active"
116429×
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
116528×
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
116617×
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
11673×
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
1168188×
        });
1169162×
        if other {
11703×
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
1171159×
        }
1172162×
        other
1173162×
    };
1174159×
    let Some(job) =
1175228×
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
1176
    else {
117769×
        return Ok(StatusCode::NO_CONTENT.into_response());
1178
    };
1179
    // The build it runs on, for waves of runner updates (oxygenflow#84).
1180159×
    fdb::set_runner_version(&app.db, job.id, &crate::runner_updates::version_of(&me_now.version)).await?;
1181
    // Why here (oxygenflow#42).
1182159×
    let waited =
1183159×
        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);
1184159×
    let rootfs = rootfs_of(&job);
1185
    // A WASM job has no image (#323), so nothing to hold or fetch.
1186159×
    let mut why = Vec::new();
1187159×
    if !rootfs.is_empty() {
1188158×
        why.push(if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
11895×
            "holds the image".to_string()
1190
        } else {
1191153×
            "fetches the image from the hub".to_string()
1192
        });
11931×
    }
1194159×
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
11952×
        why.insert(
1196
            0,
11972×
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
1198
        );
1199157×
    }
1200159×
    match running.get(&me.id).copied().unwrap_or(0) {
1201137×
        0 => why.push("idle".into()),
120222×
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
1203
    }
1204159×
    if let Some(sp) = me_now.speed {
12053×
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
1206156×
    }
1207159×
    if let Some(w) = waited {
12083×
        why.push(format!("after {} s held for a better runner", w / 1000));
1209156×
    }
1210159×
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
1211159×
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
1212159×
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1213159×
    let row =
1214159×
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1215159×
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
1216159×
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
1217
    // The job's limit: its own timeout, else its sandbox's, else the site
1218
    // default, never past the site maximum; recorded for the reaper.
1219159×
    let limits = limits(&app).await;
1220159×
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
1221159×
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
1222159×
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
1223
    // ...and never past the runner's own limit, when it has one (#98).
1224159×
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
12250×
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
1226159×
    }
1227159×
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
1228159×
    let claim = Claim {
1229159×
        job_id: job.id,
1230159×
        run_id: run.id,
1231159×
        repo: format!("{}/{}", row.owner, row.name),
1232159×
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
1233159×
        token: secret,
1234159×
        heads: run.head_list().into_iter().map(String::from).collect(),
1235159×
        pipeline_hash: run.pipeline_hash.clone(),
1236159×
        job: spec,
1237159×
        sandbox,
1238159×
        timeout_s,
1239159×
        subrepos: vec![],
1240159×
        state: run.state.clone(),
1241159×
        bookmark: run.bookmark.clone(),
1242159×
        run_number: run.number,
1243
        // The site's, the owner's and the repository's variables (#102).
1244159×
        env: crate::variables::env_for_repo(&app.db, &row).await?,
1245159×
        secrets: vec![],
1246159×
        inputs: vec![],
1247
        // For a runner that must leave: finish it, or hand it back
1248
        // (oxygenflow#76).
1249159×
        expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64),
1250
        // What an attempt handed back saved, to resume from (oxygenflow#77).
1251159×
        checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None },
1252
    };
1253159×
    let mut claim = claim;
1254159×
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
1255
    // The secrets the job declares, as files (#107); one not set is said
1256
    // at the top of the log and left out, so the job fails on the file.
1257159×
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
1258159×
    let found = crate::secrets::for_job(&app, &row, &names).await?;
1259
    // The identities it asks for (#103): a token each, as a file.
1260
    // An environment goes in its tokens only once the job was approved
1261
    // (oxygenflow#59): the approval is the environment's protection.
1262159×
    let environment = claim.job.environment.clone().filter(|_| job.approval == "approved");
1263159×
    if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) {
12640×
        left_out.push(format!(
12650×
            "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)"
12660×
        ));
1267159×
    }
1268159×
    if !claim.job.identities.is_empty() {
12693×
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
12703×
        for i in &claim.job.identities {
12713×
            let token = issuer.mint(&crate::oidc::JobClaims {
12723×
                issuer: app.config.base_url(),
12733×
                audience: &i.audience,
12743×
                owner: &row.owner,
12753×
                name: &row.name,
12763×
                visibility: &row.visibility,
12773×
                bookmark: &run.bookmark,
12783×
                run_number: run.number,
12793×
                job: &job.name,
12803×
                state: &run.state,
12813×
                runner: &runner,
12823×
                actor: &run.actor,
12833×
                environment: environment.as_deref(),
12843×
                approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()),
12853×
                approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()),
12863×
                now_ms: db::now_ms(),
12873×
                ttl_s: timeout_s,
12883×
            });
12893×
            claim.secrets.push(flow_core::api::SecretInput {
12903×
                name: format!("identity:{}", i.audience),
12913×
                path: i.mount_path.clone(),
12923×
                value: token,
12933×
            });
12943×
        }
1295156×
    }
1296159×
    for s in &claim.job.secrets {
12972×
        match found.iter().find(|(n, _)| *n == s.name) {
12981×
            Some((name, value)) => {
12991×
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
1300
            }
13011×
            None => left_out.push(format!(
1302
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
1303
                s.name, s.mount_path
1304
            )),
1305
        }
1306
    }
1307159×
    if !ids.is_empty() {
130828×
        fdb::set_also_read(&app.db, job.id, &ids).await?;
1309131×
    }
1310
    // Say at the top of the job's log what it will not have (oxygenhub#56),
1311
    // rather than let it fail later on missing files.
1312159×
    if !left_out.is_empty() {
13132×
        let records: Vec<flow_core::api::Record> =
13142×
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
13152×
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
1316157×
    }
1317159×
    claim.subrepos = inputs;
1318
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
1319
    // producer's receipt names; the runner checks what it fetches against it.
1320159×
    match artifact_inputs(&app, &job, &claim.job).await {
1321159×
        Ok(found) => claim.inputs = found,
13220×
        Err(why) => {
13230×
            let error = format!("inputs: {why}");
13240×
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
13250×
                .await?;
13260×
            return Err(ApiError(StatusCode::CONFLICT, error));
1327
        }
1328
    }
1329159×
    Ok(Json(claim).into_response())
1330233×
}
1331
1332
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
1333
/// run that passed with a receipt; a passing job's receipt names exactly the
1334
/// artifacts it uploaded (checked at finish), so their blobs are attested.
1335166×
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
1336166×
    if spec.inputs.is_empty() {
1337157×
        return Ok(vec![]);
13389×
    }
13399×
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
13409×
    let mut out = Vec::new();
13419×
    for i in &spec.inputs {
13429×
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
13430×
            return Err(format!("{:?} is not a job of this run", i.from));
1344
        };
13459×
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
13460×
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
13479×
        }
13489×
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
13499×
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
13500×
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
1351
        };
13529×
        out.push(flow_core::api::ArtifactInput {
13539×
            artifact: i.artifact.clone(),
13549×
            from: i.from.clone(),
13559×
            path: i.path.clone(),
13569×
            blob: a.blob.clone(),
13579×
        });
1358
    }
13599×
    Ok(out)
1360166×
}
1361
1362
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
1363
/// of a running job (oxygenflow#58), for the runner holding it.
13644×
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
13654×
    let runner = runner_auth(&app, &headers).await?;
13663×
    let job = running_job(&app, id).await?;
1367
    // A registered runner reads only the inputs of a job it holds (a
1368
    // configured token's runner names itself when it claims).
13692×
    if runner.source == "registered" && job.runner != runner.name {
13700×
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
13712×
    }
13722×
    let spec: flow_core::Job =
13732×
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
13742×
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
13752×
    let Some(i) = found.get(index) else {
13761×
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
1377
    };
13781×
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
13791×
    let row =
13801×
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
13811×
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
13821×
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
13831×
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
13841×
    let bytes = app
13851×
        .repos
13861×
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
13871×
        .await
13881×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
13891×
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
13904×
}
1391
1392
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
1393
/// labels and capacity, each list sorted.
1394228×
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
1395267×
    let sorted = |v: &[String]| {
1396267×
        let mut v = v.to_vec();
1397267×
        v.sort();
1398267×
        v
1399267×
    };
1400228×
    let info = req.info.as_ref();
1401228×
    let v = serde_json::json!({
1402228×
        "arch": arch,
1403228×
        "tiers": sorted(&req.runtimes),
1404228×
        "images": req.images.as_deref().map(sorted),
1405228×
        "labels": info.map(|i| sorted(&i.labels)),
1406228×
        "memory_mib": info.and_then(|i| i.memory_mib),
1407228×
        "cpus": info.and_then(|i| i.cpus),
1408
    });
1409228×
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
1410228×
}
1411
1412
/// A job handed back by this many different runners is unplaceable.
1413
pub const UNPLACEABLE_AFTER: usize = 3;
1414
1415
/// How long a runner holds a job without the hub hearing from it (claims,
1416
/// heartbeats every 30 s, logs) before the job is handed back
1417
/// (oxygenflow#76).
1418
pub const LEASE_MS: i64 = 3 * 60_000;
1419
1420
/// A job handed back more times than this ends as an error rather than
1421
/// bounce from runner to runner.
1422
pub const MAX_HANDBACKS: i64 = 5;
1423
1424
/// Hand job `id` back to the queue, ahead of new work (oxygenflow#76): its
1425
/// runner drained before leaving (`runner` is it), or its lease lapsed
1426
/// (`None`). The log says why, and the next attempt's follows on. False
1427
/// when the job was not running there.
14283×
pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> {
14293×
    let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) };
14303×
    let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) };
14312×
    let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0);
14322×
    let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") };
14332×
    fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok();
14342×
    tracing::info!("flow: job {id} handed back ({n}): {why}");
14352×
    if n > MAX_HANDBACKS {
14360×
        let error = format!("handed back {n} times; the last: {why}");
14370×
        let changed =
14380×
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
14390×
                .await?;
14400×
        report_changed(app, before.run_id, changed).await?;
14412×
    } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? {
14422×
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
14432×
            let summary = format!("{why}; queued again ahead of new work");
14442×
            report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await;
14450×
        }
14460×
    }
14472×
    Ok(true)
14483×
}
1449
1450
/// Hand back the jobs of runners not heard from for [`LEASE_MS`]: a phone
1451
/// that lost every network, a host that died (oxygenflow#76).
14521×
pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> {
14531×
    let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?;
14541×
    let mut n = 0;
14551×
    for (job, runner) in lapsed {
14561×
        let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000);
14571×
        if hand_back_job(app, job.id, None, &why).await? {
14581×
            n += 1;
14591×
        }
1460
    }
14611×
    Ok(n)
14621×
}
1463
1464
/// `POST /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): a runner
1465
/// handing a job back keeps its checkpoint directories, packed; kept as a
1466
/// blob of the job's repository, for the next attempt's claim to name.
14672×
pub async fn save_checkpoint(
14682×
    State(app): State<Shared>,
14692×
    headers: HeaderMap,
14702×
    Path(id): Path<i64>,
14712×
    body: axum::body::Bytes,
14722×
) -> Result<Response, ApiError> {
14732×
    let runner = runner_auth(&app, &headers).await?;
14742×
    let job = running_job(&app, id).await?;
14752×
    if runner.source == "registered" && job.runner != runner.name {
14760×
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
14772×
    }
14782×
    let spec: flow_core::Job =
14792×
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
14802×
    if spec.checkpoints.is_empty() {
14811×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name)));
14821×
    }
14831×
    let repo = job_repo(&app, &job).await?;
14841×
    let content = blake3::hash(&body).to_hex().to_string();
14851×
    let size = body.len() as i64;
14861×
    let blob = app
14871×
        .repos
14881×
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
14891×
        .await
14901×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?;
14911×
    let a = fdb::Artifact {
14921×
        job_id: job.id,
14931×
        name: fdb::CHECKPOINT.into(),
14941×
        path: spec.checkpoints.join(" "),
14951×
        blob: blob.to_hex(),
14961×
        size,
14971×
        content,
14981×
        created_ms: db::now_ms(),
14991×
    };
15001×
    fdb::add_artifact(&app.db, &a).await?;
15011×
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
15022×
}
1503
1504
/// `GET /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): the checkpoint
1505
/// a handed-back attempt saved, for the attempt now running it.
15061×
pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> {
15071×
    let runner = runner_auth(&app, &headers).await?;
15081×
    let job = running_job(&app, id).await?;
15091×
    if runner.source == "registered" && job.runner != runner.name {
15100×
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
15111×
    }
15121×
    let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?;
15131×
    let repo = job_repo(&app, &job).await?;
15141×
    let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob)
15151×
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?;
15161×
    let bytes = app
15171×
        .repos
15181×
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
15191×
        .await
15201×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?;
15211×
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
15221×
}
1523
1524
/// The repository job `job` runs for.
15252×
async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> {
15262×
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
15272×
    let row =
15282×
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
15292×
    RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))
15302×
}
1531
1532
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1533
/// runner hands back a job it holds, as it leaves (off the allowed network,
1534
/// a restart, a spot reclaim). Not a decline: the runner is not held from
1535
/// the job and it does not count towards unplaceable.
15362×
pub async fn release(
15372×
    State(app): State<Shared>,
15382×
    headers: HeaderMap,
15392×
    Path(id): Path<i64>,
15402×
    Json(d): Json<flow_core::api::Decline>,
15412×
) -> Result<StatusCode, ApiError> {
15422×
    let me = runner_auth(&app, &headers).await?;
15432×
    db::runners::seen(&app.db, me.id, None, None).await?;
154418×
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
15452×
    let why = match reason.trim() {
15462×
        "" => format!("handed back by {}", me.name),
15472×
        r => format!("handed back by {}: {r}", me.name),
1548
    };
15492×
    if !hand_back_job(&app, id, Some(me.id), &why).await? {
15501×
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
15511×
    }
15521×
    Ok(StatusCode::NO_CONTENT)
15532×
}
1554
1555
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1556
/// runner will not run a job it was handed. The job is queued again and
1557
/// not offered to that runner while its profile stays the same (and for
1558
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
1559
/// different runners have declined it, it ends as unplaceable with their
1560
/// reasons. A decline is never a failed check.
1561
/// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose
1562
/// job slots are all full claims nothing, so it says it is there.
15632×
pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
15642×
    let me = runner_auth(&app, &headers).await?;
15651×
    db::runners::seen(&app.db, me.id, None, None).await?;
15661×
    Ok(StatusCode::NO_CONTENT)
15672×
}
1568
1569
/// `GET /api/v1/flow/runner/update` (oxygenhub#322): the update policy
1570
/// that applies to this runner and the build it should run, if another.
157120×
pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> {
157220×
    let me = runner_auth(&app, &headers).await?;
157319×
    db::runners::seen(&app.db, me.id, None, None).await?;
157419×
    let levels = crate::runner_updates::Levels::load(&app.db).await?;
157519×
    let policy = levels.resolve(&me);
157619×
    let hour = time::OffsetDateTime::now_utc().hour();
157719×
    let (offer, note) = crate::runner_updates::offer(&app.db, &levels, &me, &policy, hour, db::now_ms(), true).await?;
157819×
    if let Some(o) = &offer {
15794×
        crate::runner_updates::share(&app, o);
158015×
    }
158119×
    Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note }))
158220×
}
1583
1584
/// `POST /api/v1/flow/runner/update`: what the runner did about its update
1585
/// (fetched, checked, installed, a dry run's verdict, a rollback), kept on
1586
/// its page and in the audit log.
15872×
pub async fn report_update(
15882×
    State(app): State<Shared>,
15892×
    headers: HeaderMap,
15902×
    Json(r): Json<flow_core::api::UpdateReport>,
15912×
) -> Result<StatusCode, ApiError> {
15922×
    let me = runner_auth(&app, &headers).await?;
1593101×
    let note: String = r.note.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
15942×
    let note = note.trim();
15952×
    if note.is_empty() {
15960×
        return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into()));
15972×
    }
15982×
    db::runner_updates::report(&app.db, me.id, note).await?;
1599
    // A build that rolled back here is held back from every runner (#326).
16002×
    if let Some(v) = crate::runner_updates::rolled_back_version(note) {
16011×
        let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back");
16021×
        db::runner_updates::hold(&app.db, &v, &me.name, &format!("it rolled back on {} ({why})", me.name)).await?;
16031×
        db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("it rolled back on {}: {why}", me.name)).await.ok();
16041×
    }
16052×
    db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok();
16062×
    Ok(StatusCode::NO_CONTENT)
16072×
}
1608
16094×
pub async fn decline(
16104×
    State(app): State<Shared>,
16114×
    headers: HeaderMap,
16124×
    Path(id): Path<i64>,
16134×
    Json(d): Json<flow_core::api::Decline>,
16144×
) -> Result<StatusCode, ApiError> {
16154×
    let me = runner_auth(&app, &headers).await?;
16164×
    db::runners::seen(&app.db, me.id, None, None).await?;
1617137×
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
16184×
    let reason = match reason.trim() {
16194×
        "" => "no reason given".to_string(),
16204×
        r => r.to_string(),
1621
    };
16224×
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
16231×
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1624
    };
16253×
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
16263×
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
16273×
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
16283×
    if runners.len() >= UNPLACEABLE_AFTER {
16291×
        let error = format!(
1630
            "unplaceable: declined by {} runners: {}",
16311×
            runners.len(),
16323×
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1633
        );
16341×
        let changed =
16351×
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
16361×
                .await?;
16371×
        report_changed(&app, job.run_id, changed).await?;
16382×
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
16392×
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
16402×
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
16412×
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
16422×
            fdb::set_waiting(&app.db, id, &summary).await?;
16430×
        }
16440×
    }
16453×
    Ok(StatusCode::NO_CONTENT)
16464×
}
1647
1648
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1649
/// now (spec §11.4), from what each runner offered at its last claim:
1650
/// empty when one could (it waits its turn or a free slot), else what
1651
/// each one lacks. `declines` are the job's.
1652
/// What placement knows of the fleet: images' architectures, the images
1653
/// the hub holds, and the time.
1654
pub struct Fleet<'a> {
1655
    pub image_archs: &'a std::collections::HashMap<String, String>,
1656
    /// Where images can come from: the hub, or a peer in a runner's site.
1657
    pub reach: &'a crate::images::Reach,
1658
    pub now: i64,
1659
}
1660
1661
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1662
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1663
/// when it can. `declines` are the job's.
166438×
pub fn runner_lacks(
166538×
    job: &fdb::Job,
166638×
    owner: &str,
166738×
    repo_id: i64,
166838×
    r: &db::runners::Runner,
166938×
    fleet: &Fleet,
167038×
    declines: &[fdb::DeclineRow],
167138×
) -> Option<String> {
167238×
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
167338×
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
167438×
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
167538×
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
167638×
    if r.state == "draining" {
16770×
        return Some("is draining".into());
167838×
    }
167938×
    let scope = r.scope();
168038×
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
16811×
        return Some("serves other repositories".into());
168237×
    }
168337×
    let tiers = r.tier_list();
168437×
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
16851×
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
168636×
    }
168736×
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
16881×
        return Some(format!("is {}; the job needs {w}", r.arch));
168935×
    }
1690
    // A job with no image (the WASM tier) needs none on the runner, as its claim says.
169135×
    let fetches = r.fetches_images && fleet.reach.fetchable(r.id, rootfs);
169235×
    if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
16934×
        let why = if !r.fetches_images {
16944×
            ""
16950×
        } else if fleet.reach.peer_only(rootfs) {
16960×
            " (only runners hold it, none in this runner's swarm site: send it to the hub with flow rootfs push)"
1697
        } else {
16980×
            " (nor does the hub: send it with flow rootfs push)"
1699
        };
17004×
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
170131×
    }
170231×
    let labels = r.label_list();
170331×
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
17040×
        return Some(format!("lacks label {l}"));
170531×
    }
170631×
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
17071×
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
170830×
    }
170930×
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
17100×
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
171130×
    }
1712
    // The disk it learned it needs (oxygenflow#41).
171330×
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
17141×
        if free < n {
17151×
            return Some(format!(
17161×
                "has {} free; the job needs ~{} (its recent runs took up to {})",
17171×
                mib(free),
17181×
                mib(n),
17191×
                mib((n - 64) * 4 / 5)
17201×
            ));
17210×
        }
172229×
    }
172329×
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
17241×
        return Some(format!("declined it: {}", d.reason));
172528×
    }
172628×
    None
172738×
}
1728
1729
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1730
/// now (spec §11.4), from what each runner offered at its last claim:
1731
/// empty when one could (it waits its turn or a free slot), else what
1732
/// each one lacks. `declines` are the job's.
1733
#[allow(clippy::too_many_arguments)]
173423×
pub fn why_waiting(
173523×
    job: &fdb::Job,
173623×
    owner: &str,
173723×
    repo_id: i64,
173823×
    runners: &[db::runners::Runner],
173923×
    image_archs: &std::collections::HashMap<String, String>,
174023×
    declines: &[fdb::DeclineRow],
174123×
    now: i64,
174223×
    reach: &crate::images::Reach,
174323×
) -> String {
174423×
    let fleet = Fleet { image_archs, reach, now };
174525×
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
174623×
    if online.is_empty() {
17471×
        return "no runner is online".into();
174822×
    }
174922×
    let mut why = Vec::new();
175024×
    for r in &online {
175124×
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
175214×
            None => return String::new(),
175310×
            Some(w) => why.push(format!("{} {w}", r.name)),
1754
        }
1755
    }
17568×
    let more = why.len().saturating_sub(4);
17578×
    why.truncate(4);
17588×
    let mut text = format!("no runner can take it: {}", why.join("; "));
17598×
    if more > 0 {
17600×
        text.push_str(&format!("; and {more} more"));
17618×
    }
17628×
    text
176323×
}
1764
1765
/// Why a job that some runner could take still waits (oxygenhub#259): the
1766
/// runners that could take it are all busy, or have a free slot without
1767
/// the room for it now, and how many ready jobs come first (a higher
1768
/// priority, or the same one and older).
1769
#[allow(clippy::too_many_arguments)]
177011×
pub fn why_still_waiting(
177111×
    job: &fdb::Job,
177211×
    owner: &str,
177311×
    repo_id: i64,
177411×
    runners: &[db::runners::Runner],
177511×
    fleet: (&std::collections::HashMap<String, String>, &crate::images::Reach, i64),
177611×
    declines: &[fdb::DeclineRow],
177711×
    running: &std::collections::HashMap<i64, i64>,
177811×
    ahead: usize,
177911×
    behind_urgent: &[String],
178011×
) -> String {
178111×
    let fleet = Fleet { image_archs: fleet.0, reach: fleet.1, now: fleet.2 };
178211×
    let mut busy = Vec::new();
178311×
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
178411×
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
17850×
            continue;
178611×
        }
178711×
        let n = running.get(&r.id).copied().unwrap_or(0);
178811×
        let slots = r.max_jobs.max(1);
178911×
        if n >= slots {
179010×
            busy.push(format!("{} busy ({n} of {slots})", r.name));
179110×
        } else {
17921×
            busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory, CPUs or disk for it now", r.name));
17931×
        }
1794
    }
179511×
    let mut parts = Vec::new();
179611×
    if !busy.is_empty() {
179711×
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
179811×
    }
179911×
    if ahead > 0 {
18006×
        let after = match behind_urgent {
18016×
            [] => String::new(),
18021×
            [one] => format!(" (after expedited run {one})"),
18030×
            many => format!(" (after expedited runs {})", many.join(", ")),
1804
        };
18056×
        parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" }));
18065×
    }
180711×
    parts.join("; ")
180811×
}
1809
1810
/// How long a job is held for a better runner before any runner that fits
1811
/// takes it (oxygenflow#42).
1812
const BETTER_HOLD_MS: i64 = 15_000;
1813
/// A runner counts as there to take a job when it asked this recently.
1814
const POLLING_MS: i64 = 10_000;
1815
1816
/// When each job was first passed over for a better runner.
1817
/// Keyed by hub (tests run several in one process) and job.
1818
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1819
1820
/// The build cache slots each runner said at its last claim it holds
1821
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1822
/// runner; a restart forgets them until each runner's next claim.
1823
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1824
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1825
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1826
1827
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1828
/// it did not say); `None` when it holds none there.
1829338×
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1830338×
    CACHES
1831338×
        .lock()
1832338×
        .unwrap_or_else(|p| p.into_inner())
1833338×
        .as_ref()
1834338×
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1835338×
        .and_then(|held| held.get(slot).copied())
1836338×
}
1837
1838
/// A copy of a build cache kept at least this much later is the fresher
1839
/// (oxygenflow#54): closer than that, the two are as good as each other.
1840
const FRESHER_MS: i64 = 60_000;
1841
1842
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1843
struct Fit {
1844
    /// When it last kept the job's build cache, if it holds it.
1845
    cache: Option<i64>,
1846
    image: bool,
1847
    speed: i64,
1848
}
1849
1850
impl Fit {
1851
    /// Whether this fit is better than `other`: holding the job's build
1852
    /// cache counts most (a warm build beats any image fetch), and of two
1853
    /// that hold it the fresher copy; then holding the image, then speed.
185417×
    fn beats(&self, other: &Fit) -> bool {
185517×
        match (self.cache, other.cache) {
18561×
            (Some(_), None) => return true,
18571×
            (None, Some(_)) => return false,
18582×
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
185913×
            _ => {}
1860
        }
186113×
        (self.image, self.speed) > (other.image, other.speed)
186217×
    }
1863
}
1864
1865
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1866
/// `slot`.
1867179×
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1868
    Fit {
1869179×
        cache: cache_kept(hub, r.id, slot),
1870179×
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1871179×
        speed: r.speed_class.unwrap_or(0),
1872
    }
1873179×
}
1874
1875
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
18761×
fn ago(ms: i64) -> String {
18771×
    let s = ms.max(0) / 1000;
18781×
    match s {
18791×
        0..=59 => format!("{s} s"),
18801×
        60..=3599 => format!("{} min", s / 60),
18810×
        3600..=172_799 => format!("{} h", s / 3600),
18820×
        _ => format!("{} days", s / 86_400),
1883
    }
18841×
}
1885
1886321×
fn rootfs_of(job: &fdb::Job) -> String {
1887321×
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1888321×
}
1889
1890
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
18913×
fn mib(m: i64) -> String {
18923×
    if m >= 1024 {
18933×
        format!("{:.1} GiB", m as f64 / 1024.0)
1894
    } else {
18950×
        format!("{m} MiB")
1896
    }
18973×
}
1898
1899
/// One placement pass: say why each ready queued job waits.
19009×
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
19019×
    let ready = fdb::ready_queued(&app.db).await?;
19029×
    if ready.is_empty() {
19031×
        return Ok(());
19048×
    }
19058×
    let runners = db::runners::list(&app.db).await?;
19068×
    let archs = fdb::image_archs(&app.db).await?;
19078×
    let held = crate::images::reach(app).await?;
19088×
    let now = db::now_ms();
19098×
    let ranks = fdb::queued_ranks(&app.db).await?;
19108×
    let running = fdb::running_by_runner(&app.db).await?;
1911
    // Claim order, roughly: a better rank first, then the older job.
191215×
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
191315×
    let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect();
1914
    // Expedited runs (rank 0) by name, for the jobs they moved down (#291).
19158×
    let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new();
191615×
    for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) {
19175×
        if urgent.contains_key(&j.run_id) {
19182×
            continue;
19193×
        }
19203×
        if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? {
19213×
            if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
19223×
                urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number));
19233×
            }
19240×
        }
1925
    }
1926
    // Runs whose waiting notes changed: their pages hear of it now.
19278×
    let mut changed = std::collections::BTreeSet::new();
192815×
    for (job, owner, repo_id) in ready {
192915×
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
193015×
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
193115×
        if why.is_empty() {
193211×
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
193329×
            let ahead = order.iter().filter(|o| **o < mine).count();
193411×
            let mut behind: Vec<String> = Vec::new();
193511×
            if mine.0 > 0 {
193614×
                for o in order.iter().filter(|o| o.0 == 0 && **o < mine) {
19372×
                    let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue };
19382×
                    if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) {
19391×
                        behind.push(label.clone());
19401×
                    }
1941
                }
19425×
            }
194311×
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind);
19444×
        }
194515×
        if why == job.waiting {
19460×
            continue;
194715×
        }
194815×
        fdb::set_waiting(&app.db, job.id, &why).await?;
194915×
        changed.insert(job.run_id);
1950
        // The job's check says it too, so a proposal shows why it is stuck
1951
        // (oxygenhub#28); only when the reason changes in kind, not in its
1952
        // counts (#259: a queue position moves every pass, and each check
1953
        // report reaches webhooks).
19541754×
        let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>();
195515×
        if kind(&why) == kind(&job.waiting) {
19560×
            continue;
195715×
        }
195815×
        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?) {
195915×
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
196015×
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
19610×
        }
1962
    }
196311×
    for run_id in changed {
196411×
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
196511×
    }
19668×
    Ok(())
19679×
}
1968
1969
/// Run a placement pass every 15 seconds.
1970199×
pub async fn placement(app: Shared) {
1971
    loop {
1972200×
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
19731×
        if let Err(e) = explain_waits(&app).await {
19740×
            tracing::warn!("flow: placement pass: {e}");
19751×
        }
1976
    }
1977
}
1978
1979
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1980412×
pub(crate) async fn pins_in(
1981412×
    app: &Shared,
1982412×
    name: crate::repos::RepoName,
1983412×
    heads: Vec<oxy_core::hash::ChangeHash>,
1984412×
) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> {
1985412×
    app.repos
1986412×
        .read(&name, move |repo, cache| {
1987412×
            let loaded = cache.load(repo, &heads)?;
1988412×
            let mut out = Vec::new();
19891343×
            for (path, f) in &loaded.snapshot.files {
19901343×
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
19911300×
                    continue;
199243×
                }
199343×
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
199443×
                    if let Some(b) = blobs.first() {
199543×
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
199643×
                            .read_blob(b)
199743×
                            .map_err(crate::repos::RepoError::Oxy)
199843×
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
199943×
                        {
200043×
                            out.push((path.clone(), id, heads));
200143×
                        }
20020×
                    }
20030×
                }
2004
            }
2005412×
            Ok(out)
2006412×
        })
2007412×
        .await
2008412×
        .map_err(|e| e.to_string())
2009412×
}
2010
2011
/// How deep subrepos of subrepos are followed.
2012
const MAX_SUBREPO_DEPTH: usize = 3;
2013
2014
/// The Oxygen subrepos the run's state pins, and those their states pin in
2015
/// turn (parents first, paths joined), that live on this hub and that the
2016
/// run's pusher may read: what the job's workspace needs beside its own
2017
/// tree. A pin the hub cannot serve is left out, with what it pins, and
2018
/// the third list says why, for the job's log.
2019159×
async fn subrepo_inputs(
2020159×
    app: &Shared,
2021159×
    row: &db::repos::RepoRow,
2022159×
    run: &fdb::Run,
2023159×
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
2024159×
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
2025159×
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2026159×
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2027159×
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
2028159×
    let mut inputs = Vec::new();
2029159×
    let mut ids = Vec::new();
2030159×
    let mut left_out = Vec::new();
2031159×
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
2032
    // (path prefix, repository, heads, depth), parents before children.
2033159×
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
2034347×
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
2035188×
        if depth == MAX_SUBREPO_DEPTH {
20360×
            continue;
2037188×
        }
2038188×
        for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() {
203930×
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
204030×
            let mut hosted = false;
204145×
            for other in &rows {
204245×
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
204345×
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
204445×
                if !matches {
204515×
                    continue;
204630×
                }
204730×
                hosted = true;
204830×
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
2049
                // A Diffusion bump's run reads the upstream it bumps: the one
2050
                // the consumer pins (someone who may write it committed the
2051
                // pin), and no other (#150).
205230×
                let diffused = run.actor == crate::diffusion::ACCOUNT
205318×
                    && run.bookmark.starts_with("diffusion/")
205418×
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id = $2")
205518×
                        .bind(run.repo_id)
205618×
                        .bind(other.id)
205718×
                        .fetch_one(&app.db)
205818×
                        .await
205918×
                        .unwrap_or(0)
2060
                        > 0;
206130×
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
206229×
                    inputs.push(flow_core::api::SubrepoInput {
206329×
                        path: path.clone(),
206429×
                        repo: format!("{}/{}", other.owner, other.name),
206529×
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
206665×
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
2067
                    });
206829×
                    if !ids.contains(&other.id) {
206929×
                        ids.push(other.id);
207029×
                    }
207129×
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
20721×
                } else {
20731×
                    left_out.push(format!(
20741×
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
20751×
                        other.owner, other.name
20761×
                    ));
20771×
                }
207830×
                break;
2079
            }
208030×
            if !hosted {
20810×
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
208230×
            }
2083
        }
2084
    }
2085159×
    (inputs, ids, left_out)
2086159×
}
2087
2088137×
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
2089137×
    match fdb::job(&app.db, id).await? {
2090137×
        Some(j) if j.status == "running" => Ok(j),
20913×
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
20920×
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
2093
    }
2094137×
}
2095
2096
/// `POST /api/v1/flow/jobs/{id}/log`.
20979×
pub async fn log(
20989×
    State(app): State<Shared>,
20999×
    headers: HeaderMap,
21009×
    Path(id): Path<i64>,
21019×
    Json(mut batch): Json<LogBatch>,
21029×
) -> Result<StatusCode, ApiError> {
21039×
    let me = runner_auth(&app, &headers).await?;
21049×
    let job = running_job(&app, id).await?;
2105
    // A runner busy with its only job claims nothing, so its log and
2106
    // finish calls are what keep it online (oxygenhub#78).
21078×
    db::runners::seen(&app.db, me.id, None, None).await?;
2108
    // An empty batch asks only whether the job still runs: a cancelled
2109
    // one answers 409 and its runner stops it (oxygenflow#32).
21108×
    if batch.records.is_empty() {
21110×
        return Ok(StatusCode::NO_CONTENT);
21128×
    }
2113
    // A secret's value never reaches the stored log (#107).
21148×
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
21158×
        Ok(spec) if !spec.secrets.is_empty() => {
21161×
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
21171×
            let row = match run {
21181×
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
21190×
                None => None,
2120
            };
21211×
            match row {
21221×
                Some(row) => {
21232×
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
21241×
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
2125
                }
21260×
                None => vec![],
2127
            }
2128
        }
21297×
        _ => vec![],
2130
    };
21318×
    let kid = app.oidc.get().map(|i| i.kid.clone());
213221×
    for r in &mut batch.records {
213321×
        if !values.is_empty() {
21342×
            r.text = crate::secrets::mask(&r.text, &values);
213519×
        }
213621×
        if let Some(kid) = &kid {
21371×
            r.text = crate::oidc::mask(&r.text, kid);
213820×
        }
213921×
        if r.text.len() > MAX_RECORD_TEXT {
21401×
            let mut cut = MAX_RECORD_TEXT;
21411×
            while !r.text.is_char_boundary(cut) {
21420×
                cut -= 1;
21430×
            }
21441×
            r.text.truncate(cut);
214520×
        }
214621×
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
21474×
            r.stream = "console".into();
214817×
        }
2149
    }
21508×
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
21518×
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
21520×
        tracing::info!("flow: job {} reached the log limit", job.id);
21538×
    }
21548×
    Ok(StatusCode::NO_CONTENT)
21559×
}
2156
2157
/// Largest artifact the hub keeps; the runner refuses larger ones first.
2158
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
2159
2160
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
2161
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
2162
/// repository. Answers `{"blob"}`.
216315×
pub async fn artifact(
216415×
    State(app): State<Shared>,
216515×
    headers: HeaderMap,
216615×
    Path((id, name)): Path<(i64, String)>,
216715×
    body: axum::body::Bytes,
216815×
) -> Result<Response, ApiError> {
216915×
    runner_auth(&app, &headers).await?;
217015×
    let job = running_job(&app, id).await?;
217115×
    let spec: flow_core::Job =
217215×
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
217315×
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
21741×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
2175
    };
217614×
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
217714×
    let row =
217814×
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
217914×
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2180
    // A report's artifact is its manifest (#127): checked, and its files
2181
    // found in the store, before it is kept.
218214×
    let report = match out.kind {
21832×
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
218412×
        flow_core::OutputKind::Artifact => None,
2185
    };
218613×
    let content = blake3::hash(&body).to_hex().to_string();
218713×
    let size = body.len() as i64;
218813×
    let artifact = report.is_none();
218913×
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
219013×
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
219113×
    let blob = app
219213×
        .repos
219313×
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
219413×
        .await
219513×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
219613×
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
219713×
    fdb::add_artifact(&app.db, &a).await?;
219813×
    if let Some((manifest, declaration)) = report {
21991×
        let origin = crate::reports::Origin {
22001×
            repo_id: row.id,
22011×
            run_id: run.id,
22021×
            run_number: run.number,
22031×
            job_id: job.id,
22041×
            job: &job.name,
22051×
            bookmark: &run.bookmark,
22061×
            state: &run.state,
22071×
        };
22081×
        let said = match declaration {
22091×
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
22101×
                Ok(id) => {
22111×
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
22121×
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
22131×
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
2214
                }
22150×
                Err(e) => format!("report {}: not indexed: {e}", a.name),
2216
            },
22170×
            Err(why) => {
22180×
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
22190×
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
22200×
                format!("report {}: report.toml: {why}", a.name)
2221
            }
2222
        };
22231×
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
22241×
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
222512×
    }
2226
    // A coverage report (#113) is read now; what it says, or why it could
2227
    // not be read, goes in the job's log. It never fails the upload.
222813×
    if let Some(bytes) = report_bytes {
22293×
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
22303×
            Ok(text) | Err(text) => text,
2231
        };
22323×
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
22333×
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
223410×
    }
2235
    // A test report (#114) likewise.
223613×
    if let Some(bytes) = junit_bytes {
22374×
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
22384×
            Ok(text) | Err(text) => text,
2239
        };
22404×
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
22414×
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
22429×
    }
224313×
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
224415×
}
2245
2246
/// A check per metric with a threshold (#127, docs/reports.md §5), named
2247
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
2248
/// `fail_above` or `fail_below`, which a proposal may require.
22491×
async fn report_threshold_checks(
22501×
    app: &Shared,
22511×
    row: &db::repos::RepoRow,
22521×
    run: &fdb::Run,
22531×
    name: &str,
22541×
    id: i64,
22551×
    metrics: &[crate::reports::Metric],
22561×
) {
22571×
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
22581×
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
22591×
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
22601×
        let above = m.fail_above.filter(|a| m.value > *a);
22611×
        let below = m.fail_below.filter(|b| m.value < *b);
22621×
        let (status, summary) = match (above, below) {
22630×
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
22640×
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
2265
            _ => {
22661×
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
22671×
                    .into_iter()
22681×
                    .flatten()
22691×
                    .collect();
22701×
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
2271
            }
2272
        };
22731×
        let check = format!("report/{name}/{}", m.key);
22741×
        if let Ok(c) = db::hooks::upsert_check(
22751×
            &app.db,
22761×
            row.id,
22771×
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
2278
        )
22791×
        .await
2280
        {
22811×
            let api = crate::api::check_of(&c);
22821×
            app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
22831×
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
22840×
        }
2285
    }
22861×
}
2287
2288
/// Check report manifest `body` (#127) and that the repository holds every
2289
/// file it names; answers it with the report's declaration, or why that
2290
/// could not be read.
22912×
async fn report_manifest(
22922×
    app: &Shared,
22932×
    repo: &RepoName,
22942×
    name: &str,
22952×
    body: &[u8],
22962×
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
22972×
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
22982×
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
22997×
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
23002×
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
23012×
    let found = app
23022×
        .repos
23032×
        .read(repo, move |r, _| {
23047×
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
23051×
                return Ok(Err(path.clone()));
23061×
            }
23071×
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
23082×
        })
23092×
        .await
23102×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
23112×
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
23121×
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
23131×
    Ok((manifest, declaration))
23142×
}
2315
2316
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
2317
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
2318
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
2319
/// the files follows as the report's artifact.
23205×
pub async fn report_file(
23215×
    State(app): State<Shared>,
23225×
    headers: HeaderMap,
23235×
    Path((id, name)): Path<(i64, String)>,
23245×
    body: axum::body::Bytes,
23255×
) -> Result<Response, ApiError> {
23265×
    runner_auth(&app, &headers).await?;
23275×
    let job = running_job(&app, id).await?;
23285×
    let spec: flow_core::Job =
23295×
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
23306×
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
23311×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
23324×
    }
23334×
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
23344×
    if !crate::reports::path_ok(path) {
23351×
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
23363×
    }
23373×
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
23383×
    let row =
23393×
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
23403×
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
23413×
    let blob = app
23423×
        .repos
23433×
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
23443×
        .await
23453×
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
23463×
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
23475×
}
2348
2349
/// Parse and keep a job's JUnit report: what it says, or why not.
23504×
async fn read_junit(
23514×
    app: &Shared,
23524×
    row: &db::repos::RepoRow,
23534×
    run: &fdb::Run,
23544×
    job: &fdb::Job,
23554×
    blob: &str,
23564×
    bytes: axum::body::Bytes,
23574×
) -> Result<String, String> {
23584×
    if bytes.len() > crate::junit::MAX_REPORT {
23590×
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
23604×
    }
23614×
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
23624×
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
23634×
        .await
23644×
        .map_err(|e| format!("tests: {e}"))?
23654×
        .map_err(|e| format!("tests: {e}"))?;
23664×
    let origin = crate::junit::Origin {
23674×
        repo_id: row.id,
23684×
        run_id: run.id,
23694×
        run_number: run.number,
23704×
        job_id: job.id,
23714×
        job: &job.name,
23724×
        bookmark: &run.bookmark,
23734×
        state: &run.state,
23744×
        blob,
23754×
    };
23764×
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
23774×
    let r = crate::junit::for_run(&app.db, run.id)
23784×
        .await
23794×
        .map_err(|e| format!("tests: {e}"))?
23804×
        .into_iter()
23814×
        .find(|r| r.job_id == job.id)
23824×
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
2383
    // The `tests` check on the run's state: what fails, and which of those
2384
    // passed in the same job's latest report on the default bookmark, so a
2385
    // proposal names what it broke.
23864×
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
23874×
    let newly = match &base {
23882×
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
23893×
        _ => vec![],
2390
    };
23914×
    let (status, summary) = if r.broken() == 0 {
23922×
        ("passed", format!("{} passed", r.passed))
23932×
    } else if let Some(b) = &base {
23941×
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
23951×
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
23961×
        let new = if newly.is_empty() {
23970×
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
2398
        } else {
23991×
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
2400
        };
24011×
        ("failed", format!("{} failing; {new}", r.broken()))
2402
    } else {
24031×
        ("failed", format!("{} failing", r.broken()))
2404
    };
24054×
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
24064×
    if let Ok(c) = db::hooks::upsert_check(
24074×
        &app.db,
24084×
        row.id,
24094×
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
2410
    )
24114×
    .await
2412
    {
24134×
        let api = crate::api::check_of(&c);
24144×
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
24154×
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
24160×
    }
24174×
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
24184×
}
2419
2420
/// Parse and keep a job's coverage report: what it says, or why not.
24213×
async fn read_coverage(
24223×
    app: &Shared,
24233×
    repo: &RepoName,
24243×
    row: &db::repos::RepoRow,
24253×
    run: &fdb::Run,
24263×
    job: &fdb::Job,
24273×
    blob: &str,
24283×
    bytes: axum::body::Bytes,
24293×
) -> Result<String, String> {
24303×
    if bytes.len() > crate::coverage::MAX_REPORT {
24310×
        return Err(format!(
24320×
            "coverage: the report is {} MiB; at most {} MiB is read",
24330×
            bytes.len() >> 20,
24340×
            crate::coverage::MAX_REPORT >> 20
24350×
        ));
24363×
    }
24373×
    let text = String::from_utf8(bytes.to_vec())
24383×
        .map_err(|_| "coverage: the report is not text (LCOV, Cobertura XML or JaCoCo XML is expected)".to_string())?;
24393×
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
24403×
        .await
24413×
        .map_err(|e| format!("coverage: {e}"))?
24423×
        .map_err(|e| format!("coverage: {e}"))?;
24432×
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
24442×
    let in_state: std::collections::HashSet<String> = app
24452×
        .repos
24462×
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
24472×
        .await
24482×
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
24492×
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
24502×
    let origin = crate::coverage::Origin {
24512×
        repo_id: row.id,
24522×
        run_id: Some(run.id),
24532×
        run_number: run.number,
24542×
        job_id: Some(job.id),
24552×
        uploader: "",
24562×
        job: &job.name,
24572×
        bookmark: &run.bookmark,
24582×
        state: &run.state,
24592×
        heads: &heads,
24602×
        blob,
24612×
        format,
24622×
    };
24632×
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
24642×
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
24650×
        return Err("coverage: the report was not kept".into());
2466
    };
2467
    // The `coverage` check on the run's state (#113): against the default
2468
    // bookmark, under the repository's rules.
24692×
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
24702×
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
24712×
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
24722×
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
24732×
    if let Ok(c) = db::hooks::upsert_check(
24742×
        &app.db,
24752×
        row.id,
24762×
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
2477
    )
24782×
    .await
2479
    {
24802×
        let api = crate::api::check_of(&c);
24812×
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
24822×
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
24830×
    }
24842×
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
24852×
    let dropped =
24862×
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
24872×
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
24883×
}
2489
2490
/// `POST /api/v1/flow/jobs/{id}/finish`.
2491102×
pub async fn finish(
2492102×
    State(app): State<Shared>,
2493102×
    headers: HeaderMap,
2494102×
    Path(id): Path<i64>,
2495102×
    Json(f): Json<Finish>,
2496102×
) -> Result<StatusCode, ApiError> {
2497102×
    let me = runner_auth(&app, &headers).await?;
2498102×
    let job = running_job(&app, id).await?;
2499101×
    db::runners::seen(&app.db, me.id, None, None).await?;
2500101×
    let status = match (f.exit_code, &f.error) {
250189×
        (Some(0), None) => "passed",
25026×
        (Some(_), _) => "failed",
25036×
        (None, _) => "error",
2504
    };
250598×
    let (receipt, receipt_hash) = match &f.receipt {
250690×
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
2507
            // A passing job's receipt names exactly the artifacts uploaded,
2508
            // in name order; a failing one's names none, though it may have
2509
            // kept `when "always"` artifacts (oxygenflow#37).
251089×
            let kept: Vec<String> =
251189×
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
251289×
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
25132×
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
251487×
            }
251587×
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
2516
        }
25171×
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
251811×
        None => (String::new(), String::new()),
2519
    };
252098×
    let error = f.error.clone().unwrap_or_default();
252198×
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
252298×
    fdb::set_phases(&app.db, job.id, &f.phases).await?;
252398×
    let changed = fdb::finish(
252498×
        &app.db,
252598×
        job.id,
252698×
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
252798×
    )
252898×
    .await?;
252998×
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
253098×
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
253198×
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
253211×
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
25337×
                    Ok((attest, signer)) => (attest, signer, String::new()),
25344×
                    Err(e) => {
25354×
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
25364×
                        (String::new(), String::new(), e)
2537
                    }
2538
                };
253911×
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
254087×
            }
25410×
        }
25420×
    }
254398×
    report_changed(&app, job.run_id, changed).await?;
254498×
    Ok(StatusCode::NO_CONTENT)
2545102×
}
2546
2547
/// Report the checks of jobs whose status changed.
2548129×
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
2549129×
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
25500×
        return Ok(());
2551
    };
2552129×
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
25530×
        return Ok(());
2554
    };
2555129×
    let mut errored_on: Vec<i64> = Vec::new();
2556163×
    let some_passed = changed.iter().any(|j| j.status == "passed");
2557129×
    if !changed.is_empty() {
2558129×
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
2559129×
    }
2560174×
    for j in changed {
2561174×
        let summary = match j.status.as_str() {
2562174×
            "passed" => match (j.reused_from(), j.reused_repo()) {
25638×
                (Some(n), Some(r)) => format!("passed in {r} run #{n} on this state, reused"),
25649×
                (Some(n), None) => format!("passed in run #{n} on this state, reused"),
256587×
                (None, _) => format!("passed in {}", duration(&j)),
2566
            },
256770×
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
256865×
            "skipped" => "skipped: a dependency did not pass".into(),
256957×
            "cancelled" => j.error.clone(),
257012×
            _ => format!("could not run: {}", j.error),
2571
        };
2572174×
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
2573174×
        if j.status == "error" {
257412×
            if let Some(r) = runner_of(app, j.id).await {
257511×
                errored_on.push(r);
257611×
            }
2577162×
        }
2578
    }
2579129×
    errored_on.dedup();
2580129×
    for r in errored_on {
258111×
        drain_if_erroring(app, r).await;
2582
    }
2583129×
    if some_passed {
258493×
        tell_approvers(app, &row, &run).await;
258536×
    }
2586129×
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2587129×
        if done.status == "failed" && !done.live() {
258811×
            tell_failed(app, &row, &done).await;
2589118×
        }
2590
        // A pass after a failure on the same bookmark: fixed (#183).
2591129×
        if done.status == "passed" {
259234×
            let before: Option<String> = sqlx::query_scalar(
259334×
                "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND id < $3 AND status IN ('passed', 'failed')
259434×
                 ORDER BY id DESC LIMIT 1",
259534×
            )
259634×
            .bind(done.repo_id)
259734×
            .bind(&done.bookmark)
259834×
            .bind(done.id)
259934×
            .fetch_optional(&app.db)
260034×
            .await?;
2601
            // Or this very run failed and a job of it was run again.
260234×
            let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number);
260334×
            let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = $1")
260434×
                .bind(&url)
260534×
                .fetch_one(&app.db)
260634×
                .await?;
260734×
            if before.as_deref() == Some("failed") || failed_here > 0 {
26082×
                tell_fixed(app, &row, &done).await;
260932×
            }
261095×
        }
26110×
    }
2612
    // A passing run of an upstream's default bookmark: consumers following
2613
    // it when green are bumped (Diffusion, #148).
2614129×
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2615129×
        if done.status == "passed" && done.bookmark == row.default_bookmark {
261644×
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
261728×
            let (app, row) = (app.clone(), row.clone());
261828×
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
2619101×
        }
2620
        // A bump's run passed: a consumer that lands its bumps itself may
2621
        // land it now (docs/diffusion.md §4.2).
2622129×
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
26236×
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
2624123×
        }
26250×
    }
2626129×
    Ok(())
2627129×
}
2628
2629
/// Make run `run` urgent (oxygenhub#244), saying why: refused without a
2630
/// reason, or when the owner already has as many urgent runs going as the
2631
/// site allows (Admin > Runners > Settings). Audited; every queued job's
2632
/// place is worked out again at once (#291). For the run page and the API.
26333×
pub async fn expedite(
26343×
    app: &Shared,
26353×
    row: &db::repos::RepoRow,
26363×
    run: &fdb::Run,
26373×
    me: &db::accounts::Account,
26383×
    reason: &str,
26393×
) -> Result<(), String> {
264032×
    let reason: String = reason.chars().filter(|c| !c.is_control()).take(200).collect();
26413×
    let reason = reason.trim();
26423×
    if reason.is_empty() {
26431×
        return Err("Say why the run is urgent.".into());
26442×
    }
26452×
    let owner = &row.owner;
2646
    // A cap on one owner's urgent runs at once, so urgent can't starve
2647
    // everyone else.
26482×
    let cap: i64 = db::accounts::setting(&app.db, "flow_expedite_cap").await.ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(2);
26492×
    let live = fdb::expedited_live(&app.db, owner).await.map_err(|e| e.to_string())?;
26502×
    if live >= cap {
26510×
        return Err(format!("{owner} already has {live} expedited run(s) going, the most at once ({cap}); wait for one to end."));
26522×
    }
26532×
    let note = format!("expedited by {}: {reason}", me.username);
26542×
    if fdb::expedite(&app.db, run.id, &note).await.map_err(|e| e.to_string())? {
26552×
        db::accounts::audit(&app.db, Some(me), "flow.expedite", &format!("{owner}/{}", row.name), &format!("#{}: {reason}", run.number))
26562×
            .await
26572×
            .ok();
2658
        // This run's page and the pages of the runs it moved down hear of it.
26592×
        if let Err(e) = explain_waits(app).await {
26600×
            tracing::warn!("flow: placing after an expedite: {e}");
26612×
        }
26622×
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
26630×
    }
26642×
    Ok(())
26653×
}
2666
2667
/// Whether another repository's pass may stand here (#296): its runner's
2668
/// signed receipt was kept (the hub checked it against the runner's bound
2669
/// key when the job finished), and read again from that repository's store
2670
/// it still verifies and its key is not revoked.
26719×
pub async fn receipt_holds(app: &Shared, from: &str, attest: &str) -> Result<(), String> {
26729×
    if attest.is_empty() {
26730×
        return Err("no signed receipt was kept".into());
26749×
    }
26759×
    let repo = crate::repos::RepoName::parse(from).ok_or("not a repository name")?;
26769×
    let at = oxy_core::hash::ObjectRef::from_hex(attest).ok_or("not a receipt's hash")?;
26779×
    let info = app.repos.read(&repo, move |r, _| Ok(r.receipt(&at)?)).await.map_err(|e| format!("the receipt: {e}"))?;
26788×
    if let Some(problem) = info.problem {
26790×
        return Err(format!("its receipt does not verify: {problem}"));
26808×
    }
26818×
    if info.revoked {
26820×
        return Err("its runner's key is revoked".into());
26838×
    }
26848×
    Ok(())
26859×
}
2686
2687
/// The account OxygenFlow's notices come from, made on first use with no
2688
/// way to sign in.
2689
pub const ACCOUNT: &str = "oxygenflow";
2690
269114×
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
269214×
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
26937×
        return Some(a);
26947×
    }
26957×
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
269614×
}
2697
269812×
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
269912×
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = $1").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
270012×
}
2701
2702
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
2703
/// are told, in their inboxes and by email as they chose, naming the jobs
2704
/// that did not pass.
270511×
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
270611×
    let Some(me) = flow_account(&app.db).await else { return };
270711×
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
270827×
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
270911×
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
271011×
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
271111×
    crate::notify::deliver(
271211×
        app,
2713
        crate::notify::Event {
271411×
            kind: "flow.failed",
271511×
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
271611×
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
271711×
            actor: &me,
271811×
            repo_id: row.id,
271911×
            thread: None,
272011×
            mention_text: "",
272111×
            direct: pusher.into_iter().map(|a| a.id).collect(),
2722
        },
2723
    )
272411×
    .await;
272511×
}
2726
2727
/// A run that passes after its bookmark's last one failed (oxygenhub#183):
2728
/// its pusher and the repository's watchers hear it is green again.
27292×
async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
27302×
    let Some(me) = flow_account(&app.db).await else { return };
27312×
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
27322×
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
27332×
    crate::notify::deliver(
27342×
        app,
2735
        crate::notify::Event {
27362×
            kind: "flow.fixed",
27372×
            title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number),
27382×
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
27392×
            actor: &me,
27402×
            repo_id: row.id,
27412×
            thread: None,
27422×
            mention_text: "",
27432×
            direct: pusher.into_iter().map(|a| a.id).collect(),
2744
        },
2745
    )
27462×
    .await;
27472×
}
2748
2749
/// A runner whose last jobs all ended in infrastructure errors is drained,
2750
/// so it stops taking jobs it fails before running them, and the
2751
/// administrators are told (oxygenflow#30).
275211×
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
275311×
    let n = app.config.flow.drain_after_errors;
275411×
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
27551×
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
27561×
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
27570×
        return;
27581×
    }
27591×
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
27601×
    let Some(me) = flow_account(&app.db).await else { return };
27611×
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default();
27621×
    crate::notify::deliver(
27631×
        app,
27641×
        crate::notify::Event {
27651×
            kind: "flow.runner_drained",
27661×
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
27671×
            url: format!("/admin/runners/{runner_id}"),
27681×
            actor: &me,
27691×
            repo_id: 0,
27701×
            thread: None,
27711×
            mention_text: "",
27721×
            direct: admins,
27731×
        },
27741×
    )
27751×
    .await;
277611×
}
2777
2778
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
2779
/// Settings, else from `hub.toml`.
2780
pub struct Limits {
2781
    /// For a job whose pipeline names none.
2782
    pub default_s: u64,
2783
    /// The most a pipeline may ask for.
2784
    pub max_s: u64,
2785
    /// Grace past a job's limit before the hub gives it up: a runner stops
2786
    /// a job at its limit and reports it within moments, so one still
2787
    /// running this long after is one the runner no longer reports (it was
2788
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
2789
    pub grace_ms: i64,
2790
}
2791
2792
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
2793
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
2794
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
2795
2796167×
pub async fn limits(app: &Shared) -> Limits {
2797501×
    let minutes = |key: &'static str| async move {
2798501×
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
27991002×
    };
2800167×
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
2801167×
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
2802167×
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
2803167×
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
2804167×
}
2805
2806
/// End every job still running past its time limit plus the grace:
2807
/// it is marked an error with the reason, the jobs that depended on it are
2808
/// skipped and its run settled, as when a runner reports an error.
28093×
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
28103×
    let limits = limits(app).await;
28113×
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
28123×
    let n = lost.len();
28133×
    for job in lost {
28143×
        let error = format!(
2815
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
28163×
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
28173×
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
2818
        );
28193×
        let changed =
28203×
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
28213×
                .await?;
28223×
        tracing::warn!("flow: job {} {error}", job.id);
28233×
        report_changed(app, job.run_id, changed).await?;
2824
    }
28253×
    Ok(n)
28263×
}
2827
2828
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
2829
/// it restarted since claiming them, or never received the claim. A
2830
/// runner claims one job at a time, so a job it holds is always listed.
28312×
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
28323×
    for job in fdb::running_on(&app.db, runner.id).await? {
28333×
        if holding.contains(&job.id) {
28341×
            continue;
28352×
        }
28362×
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
28372×
        let changed =
28382×
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
28392×
                .await?;
28402×
        tracing::warn!("flow: job {} {error}", job.id);
28412×
        report_changed(app, job.run_id, changed).await?;
2842
    }
28432×
    Ok(())
28442×
}
2845
2846
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2847
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2848
/// with a `schedule` line due then. Returns how many runs started.
28495×
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
28505×
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
28515×
    let mut started = 0;
28525×
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
28534×
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
28544×
        let b = row.default_bookmark.clone();
28554×
        let found = app
28564×
            .repos
28574×
            .read(&repo, move |r, _| {
28584×
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
28594×
                Ok(Some((st, r.store().get_state(&st)?.heads)))
28604×
            })
28614×
            .await;
28624×
        let Ok(Some((state, heads))) = found else { continue };
28634×
        if heads.is_empty() {
28640×
            continue;
28654×
        }
28664×
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
28671×
            Ok(Some(_)) => started += 1,
28683×
            Ok(None) => {}
28690×
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2870
        }
2871
    }
28725×
    started
28735×
}
2874
2875
/// The hub's clock as a [`When`](flow_core::pipeline::When).
28761×
pub fn when_now() -> flow_core::pipeline::When {
28771×
    let now = time::OffsetDateTime::now_utc();
28781×
    flow_core::pipeline::When {
28791×
        minute: now.minute() as u32,
28801×
        hour: now.hour() as u32,
28811×
        day: now.day() as u32,
28821×
        month: u8::from(now.month()) as u32,
28831×
        weekday: now.weekday().number_days_from_sunday() as u32,
28841×
    }
28851×
}
2886
2887
/// Start the scheduled runs due each minute, once a minute.
2888199×
pub async fn scheduler(app: Shared) {
2889199×
    let mut last = None;
2890
    loop {
2891200×
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
28921×
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
28931×
        if last == Some(minute) {
28940×
            continue;
28951×
        }
28961×
        last = Some(minute);
28971×
        let n = run_due(&app, when_now()).await;
28981×
        if n > 0 {
28990×
            tracing::info!("flow: {n} scheduled run(s) started");
29001×
        }
2901
    }
2902
}
2903
2904
/// Look for lost jobs once a minute.
2905199×
pub async fn reaper(app: Shared) {
2906
    loop {
2907199×
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
29080×
        if let Err(e) = requeue_lapsed(&app).await {
29090×
            tracing::warn!("flow: looking for lapsed leases: {e}");
29100×
        }
29110×
        if let Err(e) = reap_lost_jobs(&app).await {
29120×
            tracing::warn!("flow: looking for lost jobs: {e}");
29130×
        }
29140×
        match expire_approvals(&app, db::now_ms()).await {
29150×
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
29160×
            Ok(_) => {}
29170×
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2918
        }
2919
    }
2920
}
2921
2922
/// Check a runner's signed receipt for a job of `run` and keep it in the
2923
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2924
/// name, or why it was not kept. It must be the job's receipt, for the
2925
/// run's state and repository, signed by the key the runner's token first
2926
/// signed with.
292711×
pub async fn keep_receipt(
292811×
    app: &Shared,
292911×
    runner: &db::runners::Runner,
293011×
    row: &db::repos::RepoRow,
293111×
    run: &fdb::Run,
293211×
    receipt: &flow_core::Receipt,
293311×
    signed_hex: &str,
293411×
) -> Result<(String, String), String> {
2935
    use oxy_core::receipt::SignedReceipt;
293611×
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
293711×
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
293811×
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
293911×
    if sr.receipt.hash().0 != receipt.hash() {
29400×
        return Err("it signs another receipt than the job's".into());
294111×
    }
294211×
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
294311×
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
294411×
    if sr.state() != state {
29451×
        return Err("it is for another state than the run's".into());
294610×
    }
294710×
    let signer = match &sr.certificate {
2948
        // Signed by a short-lived key this hub certified for this job
2949
        // (keyless, #241): the certificate stands for the runner's key.
29504×
        Some(c) => {
29514×
            let cert = &c.cert.cert;
29524×
            if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key {
29531×
                return Err("its certificate was not issued by this hub".into());
29543×
            }
29553×
            let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark);
29563×
            if cert.subject != subject {
29570×
                return Err(format!("its certificate is for {}, not {subject}", cert.subject));
29583×
            }
29593×
            if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) {
29601×
                return Err("its certificate is for another run or job".into());
29612×
            }
2962
            // Now is when this hub accepts it: a time the runner did not choose.
29632×
            if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) {
29640×
                return Err("its certificate does not cover now: certify the key just before signing the receipt".into());
29652×
            }
29662×
            format!("{} (short-lived key)", runner.name)
2967
        }
2968
        None => {
29696×
            let key = sr.runner.hash().to_hex();
29706×
            let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
29716×
            if bound != key {
29721×
                return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
29735×
            }
29745×
            sr.runner.name.clone()
2975
        }
2976
    };
29777×
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
29787×
    let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
29797×
    let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer));
29807×
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2981
    // The transparency log (#238): the receipt, and a short-lived key's
2982
    // certificate beside it.
29837×
    let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }];
29847×
    if let Some((c, issuer)) = cert {
29852×
        entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] });
29865×
    }
29877×
    crate::tlog::log(app, entries).await;
29887×
    Ok((attest.to_hex(), signer))
298911×
}
2990
2991
/// A job's wall time, as the pages show it.
2992203×
pub fn duration(j: &fdb::Job) -> String {
2993203×
    match (j.started_ms, j.finished_ms) {
2994139×
        (Some(a), Some(b)) => human_ms(b - a),
29956×
        (Some(a), None) => human_ms(db::now_ms() - a),
299658×
        _ => "–".into(),
2997
    }
2998203×
}
2999
3000235×
pub fn human_ms(ms: i64) -> String {
3001235×
    let ms = ms.max(0);
3002235×
    if ms < 1000 {
3003190×
        format!("{ms} ms")
300445×
    } else if ms < 60_000 {
300545×
        format!("{:.1} s", ms as f64 / 1000.0)
3006
    } else {
30070×
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
3008
    }
3009235×
}
3010
3011
/// Where a live run stands among the runners' work: what its viewers may
3012
/// know of it. Jobs of other repositories are named only when those are
3013
/// public; the rest are counted.
3014
#[derive(Clone, Debug, Default, PartialEq, Eq)]
3015
pub struct QueueInfo {
3016
    /// 1-based place of the run's first queued job among all queued jobs.
3017
    pub position: Option<usize>,
3018
    /// Jobs running now, and queued jobs ahead of the run's first one.
3019
    pub running: Vec<String>,
3020
    pub ahead: Vec<String>,
3021
    /// Of those, jobs in repositories the page may not name.
3022
    pub hidden: usize,
3023
}
3024
3025
impl QueueInfo {
3026
    /// "3rd in line" and the like.
302722×
    pub fn place(&self) -> Option<String> {
302822×
        self.position.map(|n| {
302921×
            let suffix = match (n % 10, n % 100) {
30302×
                (1, 11) | (2, 12) | (3, 13) => "th",
30319×
                (1, _) => "st",
30322×
                (2, _) => "nd",
30331×
                (3, _) => "rd",
30347×
                _ => "th",
3035
            };
303621×
            format!("{n}{suffix} in line")
303721×
        })
303822×
    }
3039
}
3040
3041
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
304239×
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
304339×
    let mut info = QueueInfo::default();
304495×
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
304539×
    info.position = first.map(|i| i + 1);
304647×
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
304739×
    let mut queued_seen = 0;
3048176×
    for e in queue {
3049176×
        if e.run_id == run_id {
305079×
            continue;
305197×
        }
305297×
        let ahead = match e.status.as_str() {
305397×
            "running" => true,
3054
            _ => {
305584×
                queued_seen += 1;
305684×
                first.is_some_and(|f| queued_seen <= f)
3057
            }
3058
        };
305997×
        if !ahead {
306045×
            continue;
306152×
        }
306252×
        if e.repo_id != repo_id && e.visibility != "public" {
30635×
            info.hidden += 1;
306447×
        } else if e.status == "running" {
30659×
            info.running.push(label(e));
306638×
        } else {
306738×
            info.ahead.push(label(e));
306838×
        }
3069
    }
307039×
    info
307139×
}
3072
3073
#[cfg(test)]
3074
mod queue_tests {
3075
    use super::*;
3076
30777×
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
30787×
        fdb::QueueEntry {
30797×
            job_id,
30807×
            run_id,
30817×
            repo_id,
30827×
            owner: "o".into(),
30837×
            name: format!("r{repo_id}"),
30847×
            visibility: vis.into(),
30857×
            run_number: run_id,
30867×
            job: job.into(),
30877×
            status: status.into(),
30887×
            runner: String::new(),
30897×
            spec: String::new(),
30907×
            sandbox: String::new(),
30917×
            started_ms: None,
30927×
            priority_rank: 2,
30937×
            priority_note: String::new(),
30947×
        }
30957×
    }
3096
3097
    #[test]
30981×
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
3099
        // Running first, then queued in claim order (as `fdb::queue` returns them).
31001×
        let q = vec![
31011×
            e(1, 10, 1, "public", "build", "running"),
31021×
            e(2, 11, 2, "private", "test", "running"),
31031×
            e(3, 10, 1, "public", "test", "queued"),
31041×
            e(4, 12, 3, "private", "lint", "queued"),
31051×
            e(5, 13, 4, "public", "build", "queued"),
31061×
            e(6, 13, 4, "public", "test", "queued"),
31071×
            e(7, 14, 5, "public", "late", "queued"),
3108
        ];
31091×
        let info = queue_info(&q, 4, 13);
31101×
        assert_eq!(info.position, Some(3));
31111×
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
31121×
        assert_eq!(info.running, ["o/r1 #10 build"]);
31131×
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
31141×
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
3115
        // A private repository's own jobs are named to its viewers.
31161×
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
31171×
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
3118
        // A run with nothing queued has no place.
31191×
        assert_eq!(queue_info(&q, 1, 99).position, None);
31205×
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
31211×
        assert_eq!(
31221×
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
3123
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
3124
        );
31251×
    }
3126
}
3127
3128
#[cfg(test)]
3129
mod placement_tests {
3130
    use super::*;
3131
3132
    #[test]
31331×
    fn a_waiting_job_says_what_each_runner_lacks() {
3134
        use crate::db::runners::Runner;
31351×
        let rootfs = "ab".repeat(32);
31361×
        let job = fdb::Job {
31371×
            id: 7,
31381×
            runtime: "syscall".into(),
31391×
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
31401×
            spec: serde_json::json!({ "name": "build" }).to_string(),
31411×
            ..Default::default()
31421×
        };
31431×
        let now = db::now_ms();
31441×
        let runner = |id: i64, name: &str| Runner {
31459×
            id,
31469×
            name: name.into(),
31479×
            state: "active".into(),
31489×
            last_seen_ms: Some(now),
31499×
            arch: "x86_64".into(),
31509×
            tiers: "microvm,syscall".into(),
31519×
            images: serde_json::to_string(&[&rootfs]).unwrap(),
31529×
            memory_mib: Some(16384),
31539×
            cpus: Some(8),
31549×
            ..Default::default()
31559×
        };
31561×
        let archs = std::collections::HashMap::new();
31576×
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
31586×
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
31596×
        };
31601×
        assert_eq!(why(&[], &[]), "no runner is online");
31611×
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
31621×
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
31631×
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
31641×
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
31651×
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
31661×
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
31671×
        assert_eq!(
31681×
            why(&[microvm_only, small, other, offline], &[]),
3169
            "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"
3170
        );
31711×
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
31721×
        assert_eq!(
31731×
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
3174
            "no runner can take it: narvi is riscv64; the job needs x86_64"
3175
        );
31761×
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
31771×
        assert_eq!(why(std::slice::from_ref(&without), &[]), "no runner can take it: new does not hold image abababababab");
3178
        // A WASM job has no image: holding none is no reason to wait.
31791×
        let wasm = fdb::Job { runtime: "wasm".into(), sandbox: r#"{"memory_mib":256}"#.into(), ..job.clone() };
31801×
        let wasm_runner = Runner { tiers: "microvm,wasm".into(), ..without };
31811×
        assert_eq!(why_waiting(&wasm, "andy", 1, &[wasm_runner], &archs, &[], now, &Default::default()), "", "it only waits its turn");
31821×
        let d = fdb::DeclineRow {
31831×
            job_id: 7,
31841×
            runner_id: 1,
31851×
            runner: "earth-1".into(),
31861×
            reason: "only 512 MiB of memory is free here".into(),
31871×
            ms: now,
31881×
        };
31891×
        assert_eq!(
31901×
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
3191
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
3192
        );
31931×
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
31941×
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
31951×
    }
3196
}