OXYGENHUB
# 58ac360b3345 oxygenhub / crates / hub / src / flow.rs
143.4 KiB · Rust · last changed Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5) 8 days ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    // A drop never runs Flow (#279), whatever it holds.
26
    if row.is_drop() {
27
        return false;
28
    }
29
    match row.flow.as_str() {
30
        // `own`: on, run only by the repository's or its owner's runners (#225).
31
        "on" | "own" => true,
32
        "off" => false,
33
        _ => app.config.flow.allows(&row.owner, &row.name),
34
    }
35
}
36
37
/// A bookmark moved by a push: start a run when the repository is allowed
38
/// and the new state has a pipeline.
39
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
40
    if heads.is_empty() || !enabled(app, row) {
41
        return;
42
    }
43
    // A pushed tag runs the jobs whose `on` names it (oxygenflow#24).
44
    let started = match bookmark.strip_prefix("tags/") {
45
        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.
49
            if let Some((change, marker)) = skip_asked(app, row, bookmark, heads).await {
50
                skip_run(app, row, bookmark, state, heads, actor, &change, marker).await;
51
                return;
52
            }
53
            // It supersedes the push runs still going on the bookmark (#267).
54
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
55
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
56
        }
57
    };
58
    if let Err(e) = started {
59
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
60
    }
61
}
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.
66
fn reusable(job: &flow_core::Job) -> bool {
67
    job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty()
68
}
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.
77
pub fn skip_marker(message: &str) -> Option<&'static str> {
78
    let m = outside_code(message).to_lowercase();
79
    SKIP_MARKERS.into_iter().find(|k| m.contains(k))
80
}
81
82
/// `text` without its fenced blocks (lines between ``` fences) and inline
83
/// code spans (between backticks).
84
fn outside_code(text: &str) -> String {
85
    let mut out = String::new();
86
    let mut fenced = false;
87
    for line in text.lines() {
88
        if line.trim_start().starts_with("```") {
89
            fenced = !fenced;
90
            continue;
91
        }
92
        if fenced {
93
            continue;
94
        }
95
        // Inline spans: every other backtick-separated piece is code; an
96
        // unmatched backtick leaves the rest as text.
97
        let pieces: Vec<&str> = line.split('`').collect();
98
        let closed = pieces.len() % 2 == 1;
99
        for (i, piece) in pieces.iter().enumerate() {
100
            let in_code = i % 2 == 1 && (closed || i + 1 < pieces.len());
101
            if !in_code {
102
                out.push_str(piece);
103
                out.push(' ');
104
            }
105
        }
106
        out.push('\n');
107
    }
108
    out
109
}
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`.
113
pub fn skip_allowed(row: &db::repos::RepoRow, bookmark: &str) -> bool {
114
    match row.flow_skip.as_str() {
115
        "never" => false,
116
        "others" => bookmark != row.default_bookmark,
117
        _ => true,
118
    }
119
}
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.
123
async fn skip_asked(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<(ChangeHash, &'static str)> {
124
    if !skip_allowed(row, bookmark) {
125
        return None;
126
    }
127
    let repo = RepoName::new(&row.owner, &row.name)?;
128
    let h = heads.to_vec();
129
    let newest = app
130
        .repos
131
        .read(&repo, move |r, _| {
132
            let mut best: Option<(oxy_core::objects::Hlc, ChangeHash, String)> = None;
133
            for x in &h {
134
                let (c, _) = r.store().get_change_lazy(x)?;
135
                if best.as_ref().is_none_or(|(t, _, _)| c.hlc > *t) {
136
                    best = Some((c.hlc, *x, c.message.clone()));
137
                }
138
            }
139
            Ok(best)
140
        })
141
        .await
142
        .ok()??;
143
    skip_marker(&newest.2).map(|m| (newest.1, m))
144
}
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)]
150
async fn skip_run(
151
    app: &Shared,
152
    row: &db::repos::RepoRow,
153
    bookmark: &str,
154
    state: &str,
155
    heads: &[ChangeHash],
156
    actor: &str,
157
    change: &ChangeHash,
158
    marker: &str,
159
) {
160
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
161
    hex.sort();
162
    let heads_s = hex.join("+");
163
    let why = format!("{marker} in change {}, pushed by {actor}", change.short(12));
164
    let base =
165
        db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger: "push", error: None };
166
    if let Err(e) = db::flow::create_skipped_run(&app.db, base, &why).await {
167
        tracing::warn!("flow: recording a skipped run of {}/{}: {e}", row.owner, row.name);
168
    }
169
}
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.
175
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
176
    if !enabled(app, row) {
177
        return Err("OxygenFlow is not enabled for this repository".into());
178
    }
179
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
180
    let b = bookmark.to_string();
181
    let found = app
182
        .repos
183
        .read(&repo, move |r, _| {
184
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
185
            Ok(Some((st, r.store().get_state(&st)?.heads)))
186
        })
187
        .await
188
        .map_err(|e| e.to_string())?;
189
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
190
    if heads.is_empty() {
191
        return Err(format!("{bookmark} has no changes yet"));
192
    }
193
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
194
        .await?
195
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
196
}
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.
200
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
201
    if !enabled(app, row) {
202
        return Err("OxygenFlow is not enabled for this repository".into());
203
    }
204
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
205
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
206
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
207
    if !known {
208
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
209
    }
210
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
211
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
212
        .await?
213
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
214
}
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.
218
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
219
    if !enabled(app, row) {
220
        return Err("OxygenFlow is not enabled for this repository".into());
221
    }
222
    if run.live() {
223
        return Err(format!("run #{} is still {}", run.number, run.status));
224
    }
225
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
226
    if heads.is_empty() {
227
        return Err("the run names no changes".into());
228
    }
229
    // A rerun repeats its run's event: the jobs it had are the jobs it gets.
230
    let trigger = format!("rerun:{}:{}", run.number, original_event(run));
231
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger)
232
        .await?
233
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
234
}
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.
240
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
241
    if !enabled(app, row) {
242
        return Err("OxygenFlow is not enabled for this repository".into());
243
    }
244
    if run.live() {
245
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
246
    }
247
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
248
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
249
        "the job could not be queued again".to_string()
250
    })?;
251
    if changed.is_empty() {
252
        return Err(format!("run #{} has no job {job:?}", run.number));
253
    }
254
    for j in &changed {
255
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
256
    }
257
    Ok(changed.len())
258
}
259
260
/// Cancel a run still going (oxygenflow#32), its checks with it.
261
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
262
    if !run.live() {
263
        return Err(format!("run #{} is already {}", run.number, run.status));
264
    }
265
    cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| {
266
        tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
267
        "the run could not be cancelled".to_string()
268
    })
269
}
270
271
async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> {
272
    let changed = fdb::cancel_run(&app.db, run.id, why).await?;
273
    report_changed(app, run.id, changed).await
274
}
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.
280
pub async fn may_decide(
281
    app: &Shared,
282
    row: &db::repos::RepoRow,
283
    run: &fdb::Run,
284
    job: &fdb::Job,
285
    who: &db::accounts::Account,
286
) -> Result<flow_core::Approval, String> {
287
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
288
    let Some(approval) = spec.approval else { return Err(format!("{} does not wait for approval", job.name)) };
289
    if job.status != "queued" || job.approval != "waiting" {
290
        return Err(format!("{} is not waiting for approval", job.name));
291
    }
292
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
293
    if let Some(d) = job.depends_on().iter().find(|d| !jobs.iter().any(|j| &j.name == *d && j.status == "passed")) {
294
        return Err(format!("{} waits for {d} first", job.name));
295
    }
296
    let need = crate::auth::roles::Role::parse(&approval.by).unwrap_or(crate::auth::roles::Role::Maintain);
297
    let role = crate::auth::roles::role_for(&app.db, Some(who), row).await.map_err(|e| e.to_string())?;
298
    if role.is_none_or(|r| r < need) {
299
        return Err(format!("approving {} takes the {} role on {}/{}", job.name, approval.by, row.owner, row.name));
300
    }
301
    if approval.others_only && who.username == run.actor {
302
        return Err(format!("{} must be approved by someone other than {}, whose push started the run", job.name, run.actor));
303
    }
304
    Ok(approval)
305
}
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.
310
pub async fn decide(
311
    app: &Shared,
312
    row: &db::repos::RepoRow,
313
    run: &fdb::Run,
314
    name: &str,
315
    who: &db::accounts::Account,
316
    approve: bool,
317
    via: Option<&str>,
318
) -> Result<String, String> {
319
    decide_with(app, row, run, name, who, approve, Through { seal: None, via }).await
320
}
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.
332
async fn decide_with(
333
    app: &Shared,
334
    row: &db::repos::RepoRow,
335
    run: &fdb::Run,
336
    name: &str,
337
    who: &db::accounts::Account,
338
    approve: bool,
339
    through: Through<'_>,
340
) -> Result<String, String> {
341
    let Through { seal, via } = through;
342
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
343
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
344
    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.
348
    if let (Some(agent), None) = (via, seal) {
349
        if !row.agents_decide_gates {
350
            return Err(format!(
351
                "{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"
352
            ));
353
        }
354
    }
355
    if approve && approval.sealed && seal.is_none() {
356
        return Err(format!(
357
            "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}",
358
            row.owner,
359
            row.name,
360
            app.config.base_url(),
361
            row.owner,
362
            row.name,
363
            run.number
364
        ));
365
    }
366
    let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number);
367
    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).
369
    let by = match via {
370
        Some(v) => format!("{} via {v}", who.username),
371
        None => who.username.clone(),
372
    };
373
    let detail = [seal.unwrap_or(""), &via.map(|v| format!("via {v}")).unwrap_or_default()]
374
        .iter()
375
        .filter(|s| !s.is_empty())
376
        .cloned()
377
        .collect::<Vec<_>>()
378
        .join(", ");
379
    let said = if approve {
380
        if !fdb::approve_job(&app.db, job.id, &who.username, seal.unwrap_or(""), via.unwrap_or("")).await.map_err(|e| e.to_string())? {
381
            return Err(format!("{name} was decided on meanwhile"));
382
        }
383
        db::accounts::audit(&app.db, Some(who), "flow.approved", &target, &detail).await.ok();
384
        format!("approved by {by}{sealed}")
385
    } else {
386
        fdb::note_rejection(&app.db, job.id, &who.username, via.unwrap_or("")).await.map_err(|e| e.to_string())?;
387
        let why = format!("rejected by {by}{sealed}");
388
        let changed =
389
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
390
                .await
391
                .map_err(|e| e.to_string())?;
392
        report_changed(app, run.id, changed).await.ok();
393
        db::accounts::audit(&app.db, Some(who), "flow.rejected", &target, &detail).await.ok();
394
        why
395
    };
396
    let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said.clone() };
397
    fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
398
    Ok(format!("{name}: {said}"))
399
}
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.
404
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";
406
    let Ok(jobs) = fdb::jobs(&app.db, run.id).await else { return };
407
    for job in jobs.iter().filter(|j| j.status == "queued" && j.approval == "waiting") {
408
        if !job.depends_on().iter().all(|d| jobs.iter().any(|j| &j.name == d && j.status == "passed")) {
409
            continue;
410
        }
411
        let already = fdb::log(&app.db, job.id, 0, 50).await.unwrap_or_default().iter().any(|l| l.text == TOLD);
412
        if already {
413
            continue;
414
        }
415
        let mut targets = Vec::new();
416
        for a in db::accounts::accounts(&app.db).await.unwrap_or_default().iter().filter(|a| !a.blocked()) {
417
            if may_decide(app, row, run, job, a).await.is_ok() {
418
                targets.push((a.id, db::notify::Reason::Participating));
419
            }
420
        }
421
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: TOLD.into() };
422
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
423
        let title = format!("{}/{} run #{}: {} waits for your approval", row.owner, row.name, run.number, job.name);
424
        let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number);
425
        crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await;
426
    }
427
}
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.
432
pub async fn approval_statement(
433
    app: &Shared,
434
    row: &db::repos::RepoRow,
435
    run: &fdb::Run,
436
    name: &str,
437
) -> Result<(oxy_core::approval::Statement, fdb::Job), String> {
438
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
439
    let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) };
440
    let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?;
441
    let sandbox: flow_core::Sandbox = serde_json::from_str(&job.sandbox).map_err(|e| format!("stored sandbox: {e}"))?;
442
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
443
    let repo = app.repos.read(&repo_name, |r, _| Ok(r.id()?)).await.map_err(|e| e.to_string())?;
444
    let state = oxy_core::hash::ObjectRef::from_hex(&run.state).ok_or("the run has no state")?;
445
    let inputs = artifact_inputs(app, &job, &spec).await?.iter().filter_map(|i| oxy_core::hash::ObjectRef::from_hex(&i.blob)).collect();
446
    let statement = oxy_core::approval::Statement {
447
        repo,
448
        run: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, run.number),
449
        job: name.to_string(),
450
        job_hash: oxy_core::hash::ObjectRef(spec.hash(&sandbox)),
451
        state,
452
        inputs,
453
        environment: spec.environment.clone().unwrap_or_default(),
454
        approve: true,
455
    };
456
    Ok((statement, job))
457
}
458
459
/// Decide on job `name` of `run` from a signed approval (oxygenhub#235): it
460
/// must verify, cover exactly this job's statement, and be signed by a key
461
/// linked to an account (a delegated agent key is not) that may decide; it
462
/// is kept in the repository. What happened, or why not.
463
pub async fn decide_sealed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, name: &str, signed_hex: &str) -> Result<String, String> {
464
    let bytes = hex::decode(signed_hex.trim()).map_err(|_| "the signed approval is not hex".to_string())?;
465
    let sa = oxy_core::approval::SignedApproval::decode(&bytes).map_err(|e| format!("not a signed approval: {e}"))?;
466
    sa.verify().map_err(|e| format!("it does not verify: {e}"))?;
467
    let (mut expected, _) = approval_statement(app, row, run, name).await?;
468
    let given = sa.statement().map_err(|e| e.to_string())?;
469
    expected.approve = given.approve;
470
    if !sa.covers(&expected) {
471
        return Err(format!(
472
            "it approves something else than job {name} of run #{} as it is now (another run, state, plan or environment): sign again",
473
            run.number
474
        ));
475
    }
476
    let key = sa.signer.hash().to_hex();
477
    let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?;
478
    let Some((_, username)) = links.iter().find(|(k, _)| *k == key) else {
479
        return Err(format!(
480
            "key {} is not linked to an account here (link it under Settings › Keys; an agent's delegated key cannot approve)",
481
            &key[..12]
482
        ));
483
    };
484
    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")?;
485
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
486
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
487
    let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?;
488
    may_decide(app, row, run, job, &who).await?;
489
    let (rid, signers) = (sa.attest.repo, sa.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
490
    let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?;
491
    crate::tlog::log(app, vec![crate::tlog::Pending { kind: "approval", repo: rid, repo_row: Some(row.id), object: kept, signers }]).await;
492
    decide_with(app, row, run, name, &who, given.approve, Through { seal: Some(&kept.to_hex()), via: None }).await
493
}
494
495
/// End the approvals that waited too long (oxygenflow#57): from when the
496
/// jobs a waiting job depends on finished (or its run began).
497
pub async fn expire_approvals(app: &Shared, now: i64) -> sqlx::Result<usize> {
498
    let mut ended = 0;
499
    for job in fdb::waiting_for_approval(&app.db).await? {
500
        let Ok(spec) = serde_json::from_str::<flow_core::Job>(&job.spec) else { continue };
501
        let Some(approval) = spec.approval else { continue };
502
        let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? else { continue };
503
        let jobs = fdb::jobs(&app.db, run.id).await?;
504
        let wanted = job.depends_on();
505
        let deps: Vec<&fdb::Job> = jobs.iter().filter(|j| wanted.contains(&j.name)).collect();
506
        if deps.iter().any(|d| d.status != "passed") {
507
            continue;
508
        }
509
        let since = deps.iter().filter_map(|d| d.finished_ms).max().unwrap_or(run.created_ms);
510
        if now - since < approval.expires_s as i64 * 1000 {
511
            continue;
512
        }
513
        let hours = approval.expires_s as f64 / 3600.0;
514
        let why =
515
            format!("no approval within {}", if hours >= 1.0 { format!("{hours:.0}h") } else { format!("{}m", approval.expires_s / 60) });
516
        let changed =
517
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" })
518
                .await?;
519
        report_changed(app, run.id, changed).await?;
520
        ended += 1;
521
    }
522
    Ok(ended)
523
}
524
525
/// Newest run wins (oxygenhub#267): whether a new run started by `trigger`
526
/// cancels the older runs of its bookmark still live.
527
///
528
/// - A **push** run supersedes the older push runs of its bookmark: they
529
///   test states the bookmark has moved past. Diffusion's bumps start push
530
///   runs too.
531
/// - A **proposal** run (bookmark `proposal/N`, started when the
532
///   proposal's target moved, `web::proposals::settle_after_push`)
533
///   supersedes the older proposal runs of the same proposal: they test
534
///   projected states it no longer has. It leaves the proposal's source
535
///   bookmark's push runs alone: those test the source head, a different
536
///   state that other proposals or the bookmark's own checks may want.
537
/// - **Tag**, **manual** ("Run pipeline" on a bookmark or a change),
538
///   **schedule** and **rerun** runs were each asked for in their own
539
///   right (a person or a cron), so they supersede nothing and nothing
540
///   supersedes them: a rerun of an old push run tests the state it names,
541
///   on purpose. They are cancelled by hand.
542
///
543
/// Only runs of the same trigger on the same bookmark are superseded, so a
544
/// push never cancels a rerun or a proposal's run, nor the other way round.
545
fn supersedes(trigger: &str) -> bool {
546
    matches!(trigger, "push" | "proposal")
547
}
548
549
/// Cancel what run `number` supersedes ([`supersedes`]): the older runs of
550
/// `bookmark` started by the same `trigger` still queued, and those already
551
/// running unless the repository keeps them (`flow_keep_running`, Settings
552
/// › OxygenFlow). A push run's scope reaches back past them either way
553
/// ([`fdb::latest_on`]).
554
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) {
555
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return };
556
    for run in older {
557
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
558
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
559
        }
560
    }
561
}
562
563
/// A proposal landed (#263): its runs that no runner has started can no
564
/// longer change anything, and they hold the queue ahead of the target's
565
/// own run, so they are cancelled. Its source bookmark's push runs (when
566
/// that bookmark is in this repository and is not the target) and its
567
/// `proposal/N` runs; running ones are left to finish.
568
pub async fn moot_after_landing(app: &Shared, row: &db::repos::RepoRow, p: &db::proposals::ProposalRow) {
569
    let now = db::now_ms();
570
    let mut moot = Vec::new();
571
    let same_repo = p.source_repo_id.is_none_or(|id| id == row.id);
572
    if p.source_kind == "bookmark" && same_repo && !p.source_bookmark.is_empty() && p.source_bookmark != p.target {
573
        moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &p.source_bookmark, "push", now).await.unwrap_or_default());
574
    }
575
    moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &format!("proposal/{}", p.number), "proposal", now).await.unwrap_or_default());
576
    for run in moot {
577
        if let Err(e) = cancel_with(app, &run, &format!("moot: proposal #{} landed", p.number)).await {
578
            tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name);
579
        }
580
    }
581
}
582
583
/// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`,
584
/// `schedule`, and `rerun:N:EVENT` repeating EVENT.
585
fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> {
586
    use flow_core::pipeline::Event;
587
    let t = match trigger.strip_prefix("rerun:") {
588
        Some(rest) => rest.split_once(':').map(|(_, e)| e).unwrap_or("manual"),
589
        None => trigger,
590
    };
591
    if let Some(tag) = t.strip_prefix("tag:") {
592
        Event::Tag(tag)
593
    } else if t == "push" {
594
        Event::Push(bookmark)
595
    } else if t == "schedule" {
596
        Event::Schedule
597
    } else {
598
        Event::Manual
599
    }
600
}
601
602
/// The event to repeat when rerunning `run`.
603
fn original_event(run: &fdb::Run) -> String {
604
    let t = run.trigger.as_str();
605
    match t.strip_prefix("rerun:") {
606
        Some(rest) => rest.split_once(':').map(|(_, e)| e.to_string()).unwrap_or_else(|| "manual".into()),
607
        None => t.to_string(),
608
    }
609
}
610
611
/// The paths the changes a push brought touch: the changes in `heads`'
612
/// state that the bookmark's previous run did not have, and the files their
613
/// edits and tree operations name, as the new state places them. `None`
614
/// (run every job) for the bookmark's first run, or when it cannot be told.
615
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
616
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
617
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
618
    if old.is_empty() {
619
        return None;
620
    }
621
    let repo = RepoName::new(&row.owner, &row.name)?;
622
    let new = heads.to_vec();
623
    app.repos
624
        .read(&repo, move |r, cache| {
625
            let fresh = r.negotiate(&old, &new)?;
626
            let loaded = cache.load(r, &new)?;
627
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
628
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
629
            let mut paths = std::collections::BTreeSet::new();
630
            for h in fresh {
631
                let (c, _) = r.store().get_change_lazy(&h)?;
632
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
633
                for n in nodes {
634
                    if let Some(p) = by_node.get(&n) {
635
                        paths.insert((*p).clone());
636
                    }
637
                }
638
            }
639
            Ok(paths.into_iter().collect::<Vec<String>>())
640
        })
641
        .await
642
        .ok()
643
}
644
645
/// Whether a job runs for a push that touched `touched`: always without a
646
/// scope or without knowing what was touched, else when a path matches.
647
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
648
    let Some(touched) = touched else { return true };
649
    if scope.is_empty() {
650
        return true;
651
    }
652
    let mut b = globset::GlobSetBuilder::new();
653
    for g in scope {
654
        match globset::Glob::new(g) {
655
            Ok(g) => {
656
                b.add(g);
657
            }
658
            Err(_) => return true,
659
        }
660
    }
661
    let Ok(set) = b.build() else { return true };
662
    touched.iter().any(|p| set.is_match(p))
663
}
664
665
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
666
/// left out of the run, unless a job that runs depends on them.
667
#[allow(clippy::too_many_arguments)]
668
pub async fn start_run_scoped(
669
    app: &Shared,
670
    row: &db::repos::RepoRow,
671
    bookmark: &str,
672
    state: &str,
673
    heads: &[ChangeHash],
674
    actor: &str,
675
    trigger: &str,
676
    touched: Option<&[String]>,
677
) -> Result<Option<i64>, String> {
678
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
679
}
680
681
/// Start a run of the pipeline in the state `heads` make (whose state
682
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
683
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
684
/// whose access the run's subrepo reads are checked against. Returns the
685
/// run's number, or `None` when the state has no pipeline. The caller
686
/// checks that Flow is enabled.
687
pub async fn start_run(
688
    app: &Shared,
689
    row: &db::repos::RepoRow,
690
    bookmark: &str,
691
    state: &str,
692
    heads: &[ChangeHash],
693
    actor: &str,
694
    trigger: &str,
695
) -> Result<Option<i64>, String> {
696
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
697
}
698
699
#[allow(clippy::too_many_arguments)]
700
async fn start_run_with(
701
    app: &Shared,
702
    row: &db::repos::RepoRow,
703
    bookmark: &str,
704
    state: &str,
705
    heads: &[ChangeHash],
706
    actor: &str,
707
    trigger: &str,
708
    touched: Option<&[String]>,
709
) -> Result<Option<i64>, String> {
710
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
711
}
712
713
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
714
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
715
#[allow(clippy::too_many_arguments)]
716
async fn start_run_at(
717
    app: &Shared,
718
    row: &db::repos::RepoRow,
719
    bookmark: &str,
720
    state: &str,
721
    heads: &[ChangeHash],
722
    actor: &str,
723
    trigger: &str,
724
    touched: Option<&[String]>,
725
    at: Option<flow_core::pipeline::When>,
726
    only: &[String],
727
) -> Result<Option<i64>, String> {
728
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
729
    let h = heads.to_vec();
730
    let text = app
731
        .repos
732
        .read(&repo, move |r, cache| {
733
            let loaded = cache.load(r, &h)?;
734
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
735
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
736
                _ => None,
737
            })
738
        })
739
        .await
740
        .map_err(|e| format!("reading the pipeline: {e}"))?;
741
    let Some(text) = text else { return Ok(None) };
742
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
743
    hex.sort();
744
    let heads_s = hex.join("+");
745
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
746
    let id = match Pipeline::parse(&text) {
747
        // A scheduled tick cannot tell whether a pipeline it cannot read
748
        // has a schedule, so it starts nothing (#321): the push's run
749
        // already says what is wrong, once rather than every minute.
750
        Err(_) if at.is_some() => return Ok(None),
751
        Err(problems) => {
752
            let msg = problems.to_string();
753
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
754
        }
755
        Ok(p) => {
756
            let depths = p.depths();
757
            // Jobs out of the push's scope stay out, unless a kept job
758
            // needs them.
759
            // The jobs this run's event starts (`on`), out of those its
760
            // push touched (`scope`); then what they depend on.
761
            let event = match at {
762
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
763
                None => event_of(trigger, bookmark),
764
            };
765
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
766
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
767
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
768
            } else {
769
                // Named jobs (oxygenhub#130): each one this event starts.
770
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
771
                for name in only {
772
                    if !startable.contains(&name.as_str()) {
773
                        let what = if p.jobs.contains_key(name) {
774
                            "is not started by hand (no `on { manual }`)"
775
                        } else {
776
                            "is not in the pipeline"
777
                        };
778
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
779
                    }
780
                }
781
                only.iter().cloned().collect()
782
            };
783
            loop {
784
                let needed: Vec<String> = p
785
                    .order()
786
                    .into_iter()
787
                    .filter(|j| keep.contains(&j.name))
788
                    .flat_map(|j| j.depends_on.iter().cloned())
789
                    .filter(|d| !keep.contains(d))
790
                    .collect();
791
                if needed.is_empty() {
792
                    break;
793
                }
794
                keep.extend(needed);
795
            }
796
            let mut jobs: Vec<fdb::NewJob> = p
797
                .order()
798
                .into_iter()
799
                .filter(|j| keep.contains(&j.name))
800
                .map(|j| {
801
                    let sb = &p.sandboxes[&j.sandbox];
802
                    fdb::NewJob {
803
                        name: j.name.clone(),
804
                        depth: depths[j.name.as_str()] as i64,
805
                        depends: j.depends_on.clone(),
806
                        spec: serde_json::to_string(j).unwrap_or_default(),
807
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
808
                        runtime: sb.runtime.as_str().into(),
809
                        need_disk_mib: None,
810
                        reused: None,
811
                    }
812
                })
813
                .collect();
814
            // What each job needs, learned from its past runs (oxygenflow#41).
815
            for j in &mut jobs {
816
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
817
            }
818
            if jobs.is_empty() {
819
                // Nothing this event starts (or this push touched).
820
                return Ok(None);
821
            }
822
            // A job that passed on this very state, with the same spec and
823
            // sandbox, is not run again by a push (#265): main after a
824
            // proposal landed it as tested. Never one that publishes, deploys
825
            // or holds secrets, nor one whose artifacts a job of this run
826
            // takes; and not where the repository turned reuse off.
827
            if trigger == "push" && row.flow_reuse {
828
                let consumed: std::collections::HashSet<&str> = p
829
                    .order()
830
                    .into_iter()
831
                    .filter(|j| keep.contains(&j.name))
832
                    .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str()))
833
                    .collect();
834
                for j in &mut jobs {
835
                    let Some(spec) = p.jobs.get(&j.name) else { continue };
836
                    if !reusable(spec) || consumed.contains(j.name.as_str()) {
837
                        continue;
838
                    }
839
                    j.reused =
840
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
841
                    // A fork starts green where upstream proved this state (#296).
842
                    if j.reused.is_none() && row.forked_from.is_some() {
843
                        j.reused =
844
                            fdb::passed_upstream(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
845
                    }
846
                }
847
            }
848
            let hash = hex::encode(p.hash());
849
            let id =
850
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
851
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
852
                for j in jobs.iter().filter(|j| j.reused.is_none()) {
853
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
854
                }
855
                // Reused passes (#265) report as any pass does: their checks,
856
                // and a run that passed with them (Diffusion, landing a bump).
857
                if jobs.iter().any(|j| j.reused.is_some()) {
858
                    let reused: Vec<fdb::Job> =
859
                        fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect();
860
                    if let Err(e) = report_changed(app, run.id, reused).await {
861
                        tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number);
862
                    }
863
                }
864
                images_check(app, row, state, run.number, &jobs).await;
865
            }
866
            id
867
        }
868
    };
869
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
870
    if supersedes(trigger) {
871
        supersede(app, row, bookmark, trigger, run.number).await;
872
    }
873
    Ok(Some(run.number))
874
}
875
876
/// The `pipeline/images` check (#137): set failing when an image the run's
877
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
878
/// one says so before it lands rather than its runs queueing for good;
879
/// passed again by a later run of the state once every image is held.
880
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
881
    let mut pinned: Vec<String> = jobs
882
        .iter()
883
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
884
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
885
        .filter(|r| !r.is_empty())
886
        .collect();
887
    pinned.sort();
888
    pinned.dedup();
889
    if pinned.is_empty() {
890
        return;
891
    }
892
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
893
    // Nobody can be said to lack an image unless some runner is known and
894
    // every one reports what it holds.
895
    let runners: Vec<db::runners::Runner> =
896
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
897
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
898
        return;
899
    }
900
    for r in &runners {
901
        held.extend(r.image_list().unwrap_or_default());
902
    }
903
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
904
    // Only a missing image makes the check; one that was failing on this
905
    // state turns to passed once its images are held.
906
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
907
        .await
908
        .unwrap_or_default()
909
        .iter()
910
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
911
    if missing.is_empty() && !failing_before {
912
        return;
913
    }
914
    let (status, summary) = if missing.is_empty() {
915
        (
916
            "passed",
917
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
918
        )
919
    } else {
920
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
921
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
922
    };
923
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
924
    if let Ok(c) = db::hooks::upsert_check(
925
        &app.db,
926
        row.id,
927
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
928
    )
929
    .await
930
    {
931
        let api = crate::api::check_of(&c);
932
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
933
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
934
    }
935
}
936
937
/// Record a job's check; it links to the run's page by the run's number.
938
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
939
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
940
    let name = format!("flow/{job}");
941
    let c = db::hooks::upsert_check(
942
        &app.db,
943
        row.id,
944
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
945
    )
946
    .await;
947
    if let Ok(c) = c {
948
        let api = crate::api::check_of(&c);
949
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
950
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
951
    }
952
}
953
954
/// The check status a job's status shows as.
955
fn check_status(job_status: &str) -> &'static str {
956
    match job_status {
957
        "queued" => "queued",
958
        "running" => "running",
959
        "passed" => "passed",
960
        "skipped" => "skipped",
961
        "cancelled" => "cancelled",
962
        _ => "failed",
963
    }
964
}
965
966
/// The runner a bearer token belongs to: a registered runner, or one
967
/// configured with a hub.toml token (tracked from its first request). A
968
/// disabled runner is refused.
969
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
970
    let Some(t) = oxy_server::bearer_token(headers) else {
971
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
972
    };
973
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
974
    let runner = match db::runners::by_token(&app.db, &hash).await? {
975
        Some(r) => r,
976
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
977
            db::runners::configured(&app.db, &hash, "runner").await?
978
        }
979
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
980
    };
981
    if runner.state == "disabled" {
982
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
983
    }
984
    Ok(runner)
985
}
986
987
/// `POST /api/v1/flow/runners/register`: a one-time registration token
988
/// from Admin → Runners buys a runner credential, binding the runner's
989
/// signing identity (hub spec §13.2).
990
pub async fn register(
991
    State(app): State<Shared>,
992
    Json(req): Json<flow_core::api::Register>,
993
) -> Result<Json<flow_core::api::Registered>, ApiError> {
994
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
995
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
996
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
997
    if name.is_empty() {
998
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
999
    }
1000
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
1001
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
1002
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
1003
    let runner =
1004
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
1005
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
1006
        })?;
1007
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
1008
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
1009
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
1010
}
1011
1012
/// `POST /api/v1/flow/claim`.
1013
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
1014
    let me = runner_auth(&app, &headers).await?;
1015
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
1016
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
1017
    if let Some(holding) = &req.holding {
1018
        end_jobs_not_held(&app, &me, holding).await?;
1019
    }
1020
    // A draining runner finishes what it has and takes nothing new.
1021
    if me.state == "draining" {
1022
        return Ok(StatusCode::NO_CONTENT.into_response());
1023
    }
1024
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
1025
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
1026
    // What it holds and what it is (OxygenFlow spec §11): the images'
1027
    // architectures it reports are remembered for every runner's claims.
1028
    if let Some(archs) = &req.image_archs {
1029
        fdb::record_image_archs(&app.db, archs).await?;
1030
    }
1031
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
1032
    // What it offers, kept for the placement pass; a job it declined is
1033
    // not offered to it again while this stays the same (spec §11.5).
1034
    let profile = profile_of(&req, &arch);
1035
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
1036
    if let Some(f) = &req.facts {
1037
        db::runners::set_facts(&app.db, me.id, f).await?;
1038
    }
1039
    app.swarm.claimed(me.id, req.swarm.as_ref());
1040
    // The build caches it holds (oxygenflow#51), and when each last kept a
1041
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
1042
    if let Some(c) = &req.caches {
1043
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
1044
        CACHES
1045
            .lock()
1046
            .unwrap_or_else(|p| p.into_inner())
1047
            .get_or_insert_with(Default::default)
1048
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
1049
    }
1050
    let room = fdb::Room {
1051
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
1052
        memory_mib: req.free_memory_mib,
1053
        cpus: req.free_cpus,
1054
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
1055
        arch,
1056
        image_archs: fdb::image_archs(&app.db).await?,
1057
        // The hub's own images, and those a runner of its site holds (oxygenflow#94).
1058
        fetchable: if req.fetches_images { crate::images::reach(&app).await?.for_runner(me.id) } else { Default::default() },
1059
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
1060
    };
1061
    // The labels it reports now, or those it registered with, and those
1062
    // an administrator gave it (#98).
1063
    let mut labels: Vec<String> = match &req.info {
1064
        Some(i) => i.labels.clone(),
1065
        None => me.reported_label_list().into_iter().map(String::from).collect(),
1066
    };
1067
    for l in me.admin_label_list() {
1068
        if !labels.iter().any(|x| x == l) {
1069
            labels.push(l.to_string());
1070
        }
1071
    }
1072
    // Ranking (oxygenflow#42): a job another free runner would do better
1073
    // (it holds the image, or is faster) is held for it briefly.
1074
    let runners = db::runners::list(&app.db).await?;
1075
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
1076
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
1077
    let queue = fdb::queue(&app.db).await?;
1078
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
1079
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
1080
    let slots: std::collections::HashMap<i64, String> =
1081
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
1082
    let (archs, reach) = (fdb::image_archs(&app.db).await?, crate::images::reach(&app).await?);
1083
    let now = db::now_ms();
1084
    let fleet = Fleet { image_archs: &archs, reach: &reach, now };
1085
    let hub_key = app.repos.root().display().to_string();
1086
    let better = |j: &fdb::Job| -> bool {
1087
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
1088
        let rootfs = rootfs_of(j);
1089
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
1090
        let holds = holds.get_or_insert_with(Default::default);
1091
        holds.retain(|_, t| now - *t < 600_000);
1092
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
1093
            return false;
1094
        }
1095
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
1096
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
1097
        let other = runners.iter().any(|r| {
1098
            r.id != me.id
1099
                && r.state == "active"
1100
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
1101
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
1102
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
1103
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
1104
        });
1105
        if other {
1106
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
1107
        }
1108
        other
1109
    };
1110
    let Some(job) =
1111
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
1112
    else {
1113
        return Ok(StatusCode::NO_CONTENT.into_response());
1114
    };
1115
    // Why here (oxygenflow#42).
1116
    let waited =
1117
        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);
1118
    let rootfs = rootfs_of(&job);
1119
    // A WASM job has no image (#323), so nothing to hold or fetch.
1120
    let mut why = Vec::new();
1121
    if !rootfs.is_empty() {
1122
        why.push(if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
1123
            "holds the image".to_string()
1124
        } else {
1125
            "fetches the image from the hub".to_string()
1126
        });
1127
    }
1128
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
1129
        why.insert(
1130
            0,
1131
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
1132
        );
1133
    }
1134
    match running.get(&me.id).copied().unwrap_or(0) {
1135
        0 => why.push("idle".into()),
1136
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
1137
    }
1138
    if let Some(sp) = me_now.speed {
1139
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
1140
    }
1141
    if let Some(w) = waited {
1142
        why.push(format!("after {} s held for a better runner", w / 1000));
1143
    }
1144
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
1145
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
1146
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1147
    let row =
1148
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1149
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
1150
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
1151
    // The job's limit: its own timeout, else its sandbox's, else the site
1152
    // default, never past the site maximum; recorded for the reaper.
1153
    let limits = limits(&app).await;
1154
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
1155
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
1156
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
1157
    // ...and never past the runner's own limit, when it has one (#98).
1158
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
1159
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
1160
    }
1161
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
1162
    let claim = Claim {
1163
        job_id: job.id,
1164
        run_id: run.id,
1165
        repo: format!("{}/{}", row.owner, row.name),
1166
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
1167
        token: secret,
1168
        heads: run.head_list().into_iter().map(String::from).collect(),
1169
        pipeline_hash: run.pipeline_hash.clone(),
1170
        job: spec,
1171
        sandbox,
1172
        timeout_s,
1173
        subrepos: vec![],
1174
        state: run.state.clone(),
1175
        bookmark: run.bookmark.clone(),
1176
        run_number: run.number,
1177
        // The site's, the owner's and the repository's variables (#102).
1178
        env: crate::variables::env_for_repo(&app.db, &row).await?,
1179
        secrets: vec![],
1180
        inputs: vec![],
1181
        // For a runner that must leave: finish it, or hand it back
1182
        // (oxygenflow#76).
1183
        expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64),
1184
        // What an attempt handed back saved, to resume from (oxygenflow#77).
1185
        checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None },
1186
    };
1187
    let mut claim = claim;
1188
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
1189
    // The secrets the job declares, as files (#107); one not set is said
1190
    // at the top of the log and left out, so the job fails on the file.
1191
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
1192
    let found = crate::secrets::for_job(&app, &row, &names).await?;
1193
    // The identities it asks for (#103): a token each, as a file.
1194
    // An environment goes in its tokens only once the job was approved
1195
    // (oxygenflow#59): the approval is the environment's protection.
1196
    let environment = claim.job.environment.clone().filter(|_| job.approval == "approved");
1197
    if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) {
1198
        left_out.push(format!(
1199
            "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)"
1200
        ));
1201
    }
1202
    if !claim.job.identities.is_empty() {
1203
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
1204
        for i in &claim.job.identities {
1205
            let token = issuer.mint(&crate::oidc::JobClaims {
1206
                issuer: app.config.base_url(),
1207
                audience: &i.audience,
1208
                owner: &row.owner,
1209
                name: &row.name,
1210
                visibility: &row.visibility,
1211
                bookmark: &run.bookmark,
1212
                run_number: run.number,
1213
                job: &job.name,
1214
                state: &run.state,
1215
                runner: &runner,
1216
                actor: &run.actor,
1217
                environment: environment.as_deref(),
1218
                approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()),
1219
                approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()),
1220
                now_ms: db::now_ms(),
1221
                ttl_s: timeout_s,
1222
            });
1223
            claim.secrets.push(flow_core::api::SecretInput {
1224
                name: format!("identity:{}", i.audience),
1225
                path: i.mount_path.clone(),
1226
                value: token,
1227
            });
1228
        }
1229
    }
1230
    for s in &claim.job.secrets {
1231
        match found.iter().find(|(n, _)| *n == s.name) {
1232
            Some((name, value)) => {
1233
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
1234
            }
1235
            None => left_out.push(format!(
1236
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
1237
                s.name, s.mount_path
1238
            )),
1239
        }
1240
    }
1241
    if !ids.is_empty() {
1242
        fdb::set_also_read(&app.db, job.id, &ids).await?;
1243
    }
1244
    // Say at the top of the job's log what it will not have (oxygenhub#56),
1245
    // rather than let it fail later on missing files.
1246
    if !left_out.is_empty() {
1247
        let records: Vec<flow_core::api::Record> =
1248
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
1249
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
1250
    }
1251
    claim.subrepos = inputs;
1252
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
1253
    // producer's receipt names; the runner checks what it fetches against it.
1254
    match artifact_inputs(&app, &job, &claim.job).await {
1255
        Ok(found) => claim.inputs = found,
1256
        Err(why) => {
1257
            let error = format!("inputs: {why}");
1258
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1259
                .await?;
1260
            return Err(ApiError(StatusCode::CONFLICT, error));
1261
        }
1262
    }
1263
    Ok(Json(claim).into_response())
1264
}
1265
1266
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
1267
/// run that passed with a receipt; a passing job's receipt names exactly the
1268
/// artifacts it uploaded (checked at finish), so their blobs are attested.
1269
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
1270
    if spec.inputs.is_empty() {
1271
        return Ok(vec![]);
1272
    }
1273
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
1274
    let mut out = Vec::new();
1275
    for i in &spec.inputs {
1276
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
1277
            return Err(format!("{:?} is not a job of this run", i.from));
1278
        };
1279
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
1280
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
1281
        }
1282
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
1283
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
1284
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
1285
        };
1286
        out.push(flow_core::api::ArtifactInput {
1287
            artifact: i.artifact.clone(),
1288
            from: i.from.clone(),
1289
            path: i.path.clone(),
1290
            blob: a.blob.clone(),
1291
        });
1292
    }
1293
    Ok(out)
1294
}
1295
1296
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
1297
/// of a running job (oxygenflow#58), for the runner holding it.
1298
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
1299
    let runner = runner_auth(&app, &headers).await?;
1300
    let job = running_job(&app, id).await?;
1301
    // A registered runner reads only the inputs of a job it holds (a
1302
    // configured token's runner names itself when it claims).
1303
    if runner.source == "registered" && job.runner != runner.name {
1304
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1305
    }
1306
    let spec: flow_core::Job =
1307
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1308
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
1309
    let Some(i) = found.get(index) else {
1310
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
1311
    };
1312
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1313
    let row =
1314
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1315
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1316
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
1317
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
1318
    let bytes = app
1319
        .repos
1320
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1321
        .await
1322
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
1323
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1324
}
1325
1326
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
1327
/// labels and capacity, each list sorted.
1328
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
1329
    let sorted = |v: &[String]| {
1330
        let mut v = v.to_vec();
1331
        v.sort();
1332
        v
1333
    };
1334
    let info = req.info.as_ref();
1335
    let v = serde_json::json!({
1336
        "arch": arch,
1337
        "tiers": sorted(&req.runtimes),
1338
        "images": req.images.as_deref().map(sorted),
1339
        "labels": info.map(|i| sorted(&i.labels)),
1340
        "memory_mib": info.and_then(|i| i.memory_mib),
1341
        "cpus": info.and_then(|i| i.cpus),
1342
    });
1343
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
1344
}
1345
1346
/// A job handed back by this many different runners is unplaceable.
1347
pub const UNPLACEABLE_AFTER: usize = 3;
1348
1349
/// How long a runner holds a job without the hub hearing from it (claims,
1350
/// heartbeats every 30 s, logs) before the job is handed back
1351
/// (oxygenflow#76).
1352
pub const LEASE_MS: i64 = 3 * 60_000;
1353
1354
/// A job handed back more times than this ends as an error rather than
1355
/// bounce from runner to runner.
1356
pub const MAX_HANDBACKS: i64 = 5;
1357
1358
/// Hand job `id` back to the queue, ahead of new work (oxygenflow#76): its
1359
/// runner drained before leaving (`runner` is it), or its lease lapsed
1360
/// (`None`). The log says why, and the next attempt's follows on. False
1361
/// when the job was not running there.
1362
pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> {
1363
    let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) };
1364
    let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) };
1365
    let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0);
1366
    let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") };
1367
    fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok();
1368
    tracing::info!("flow: job {id} handed back ({n}): {why}");
1369
    if n > MAX_HANDBACKS {
1370
        let error = format!("handed back {n} times; the last: {why}");
1371
        let changed =
1372
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1373
                .await?;
1374
        report_changed(app, before.run_id, changed).await?;
1375
    } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? {
1376
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1377
            let summary = format!("{why}; queued again ahead of new work");
1378
            report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await;
1379
        }
1380
    }
1381
    Ok(true)
1382
}
1383
1384
/// Hand back the jobs of runners not heard from for [`LEASE_MS`]: a phone
1385
/// that lost every network, a host that died (oxygenflow#76).
1386
pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> {
1387
    let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?;
1388
    let mut n = 0;
1389
    for (job, runner) in lapsed {
1390
        let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000);
1391
        if hand_back_job(app, job.id, None, &why).await? {
1392
            n += 1;
1393
        }
1394
    }
1395
    Ok(n)
1396
}
1397
1398
/// `POST /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): a runner
1399
/// handing a job back keeps its checkpoint directories, packed; kept as a
1400
/// blob of the job's repository, for the next attempt's claim to name.
1401
pub async fn save_checkpoint(
1402
    State(app): State<Shared>,
1403
    headers: HeaderMap,
1404
    Path(id): Path<i64>,
1405
    body: axum::body::Bytes,
1406
) -> Result<Response, ApiError> {
1407
    let runner = runner_auth(&app, &headers).await?;
1408
    let job = running_job(&app, id).await?;
1409
    if runner.source == "registered" && job.runner != runner.name {
1410
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1411
    }
1412
    let spec: flow_core::Job =
1413
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1414
    if spec.checkpoints.is_empty() {
1415
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name)));
1416
    }
1417
    let repo = job_repo(&app, &job).await?;
1418
    let content = blake3::hash(&body).to_hex().to_string();
1419
    let size = body.len() as i64;
1420
    let blob = app
1421
        .repos
1422
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1423
        .await
1424
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?;
1425
    let a = fdb::Artifact {
1426
        job_id: job.id,
1427
        name: fdb::CHECKPOINT.into(),
1428
        path: spec.checkpoints.join(" "),
1429
        blob: blob.to_hex(),
1430
        size,
1431
        content,
1432
        created_ms: db::now_ms(),
1433
    };
1434
    fdb::add_artifact(&app.db, &a).await?;
1435
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1436
}
1437
1438
/// `GET /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): the checkpoint
1439
/// a handed-back attempt saved, for the attempt now running it.
1440
pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> {
1441
    let runner = runner_auth(&app, &headers).await?;
1442
    let job = running_job(&app, id).await?;
1443
    if runner.source == "registered" && job.runner != runner.name {
1444
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1445
    }
1446
    let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?;
1447
    let repo = job_repo(&app, &job).await?;
1448
    let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob)
1449
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?;
1450
    let bytes = app
1451
        .repos
1452
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1453
        .await
1454
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?;
1455
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1456
}
1457
1458
/// The repository job `job` runs for.
1459
async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> {
1460
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1461
    let row =
1462
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1463
    RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))
1464
}
1465
1466
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1467
/// runner hands back a job it holds, as it leaves (off the allowed network,
1468
/// a restart, a spot reclaim). Not a decline: the runner is not held from
1469
/// the job and it does not count towards unplaceable.
1470
pub async fn release(
1471
    State(app): State<Shared>,
1472
    headers: HeaderMap,
1473
    Path(id): Path<i64>,
1474
    Json(d): Json<flow_core::api::Decline>,
1475
) -> Result<StatusCode, ApiError> {
1476
    let me = runner_auth(&app, &headers).await?;
1477
    db::runners::seen(&app.db, me.id, None, None).await?;
1478
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1479
    let why = match reason.trim() {
1480
        "" => format!("handed back by {}", me.name),
1481
        r => format!("handed back by {}: {r}", me.name),
1482
    };
1483
    if !hand_back_job(&app, id, Some(me.id), &why).await? {
1484
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1485
    }
1486
    Ok(StatusCode::NO_CONTENT)
1487
}
1488
1489
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1490
/// runner will not run a job it was handed. The job is queued again and
1491
/// not offered to that runner while its profile stays the same (and for
1492
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
1493
/// different runners have declined it, it ends as unplaceable with their
1494
/// reasons. A decline is never a failed check.
1495
/// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose
1496
/// job slots are all full claims nothing, so it says it is there.
1497
pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
1498
    let me = runner_auth(&app, &headers).await?;
1499
    db::runners::seen(&app.db, me.id, None, None).await?;
1500
    Ok(StatusCode::NO_CONTENT)
1501
}
1502
1503
/// `GET /api/v1/flow/runner/update` (oxygenhub#322): the update policy
1504
/// that applies to this runner and the build it should run, if another.
1505
pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> {
1506
    let me = runner_auth(&app, &headers).await?;
1507
    db::runners::seen(&app.db, me.id, None, None).await?;
1508
    let policy = crate::runner_updates::Levels::load(&app.db).await?.resolve(&me);
1509
    let hour = time::OffsetDateTime::now_utc().hour();
1510
    let (offer, note) = crate::runner_updates::offer(&app.db, &me, &policy, hour).await?;
1511
    Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note }))
1512
}
1513
1514
/// `POST /api/v1/flow/runner/update`: what the runner did about its update
1515
/// (fetched, checked, installed, a dry run's verdict, a rollback), kept on
1516
/// its page and in the audit log.
1517
pub async fn report_update(
1518
    State(app): State<Shared>,
1519
    headers: HeaderMap,
1520
    Json(r): Json<flow_core::api::UpdateReport>,
1521
) -> Result<StatusCode, ApiError> {
1522
    let me = runner_auth(&app, &headers).await?;
1523
    let note: String = r.note.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1524
    let note = note.trim();
1525
    if note.is_empty() {
1526
        return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into()));
1527
    }
1528
    db::runner_updates::report(&app.db, me.id, note).await?;
1529
    // A build that rolled back here is held back from every runner (#326).
1530
    if let Some(v) = crate::runner_updates::rolled_back_version(note) {
1531
        let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back");
1532
        db::runner_updates::hold(&app.db, &v, &me.name, why).await?;
1533
        db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok();
1534
    }
1535
    db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok();
1536
    Ok(StatusCode::NO_CONTENT)
1537
}
1538
1539
pub async fn decline(
1540
    State(app): State<Shared>,
1541
    headers: HeaderMap,
1542
    Path(id): Path<i64>,
1543
    Json(d): Json<flow_core::api::Decline>,
1544
) -> Result<StatusCode, ApiError> {
1545
    let me = runner_auth(&app, &headers).await?;
1546
    db::runners::seen(&app.db, me.id, None, None).await?;
1547
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1548
    let reason = match reason.trim() {
1549
        "" => "no reason given".to_string(),
1550
        r => r.to_string(),
1551
    };
1552
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
1553
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1554
    };
1555
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
1556
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1557
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
1558
    if runners.len() >= UNPLACEABLE_AFTER {
1559
        let error = format!(
1560
            "unplaceable: declined by {} runners: {}",
1561
            runners.len(),
1562
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1563
        );
1564
        let changed =
1565
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1566
                .await?;
1567
        report_changed(&app, job.run_id, changed).await?;
1568
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1569
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1570
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1571
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1572
            fdb::set_waiting(&app.db, id, &summary).await?;
1573
        }
1574
    }
1575
    Ok(StatusCode::NO_CONTENT)
1576
}
1577
1578
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1579
/// now (spec §11.4), from what each runner offered at its last claim:
1580
/// empty when one could (it waits its turn or a free slot), else what
1581
/// each one lacks. `declines` are the job's.
1582
/// What placement knows of the fleet: images' architectures, the images
1583
/// the hub holds, and the time.
1584
pub struct Fleet<'a> {
1585
    pub image_archs: &'a std::collections::HashMap<String, String>,
1586
    /// Where images can come from: the hub, or a peer in a runner's site.
1587
    pub reach: &'a crate::images::Reach,
1588
    pub now: i64,
1589
}
1590
1591
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1592
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1593
/// when it can. `declines` are the job's.
1594
pub fn runner_lacks(
1595
    job: &fdb::Job,
1596
    owner: &str,
1597
    repo_id: i64,
1598
    r: &db::runners::Runner,
1599
    fleet: &Fleet,
1600
    declines: &[fdb::DeclineRow],
1601
) -> Option<String> {
1602
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1603
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1604
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1605
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1606
    if r.state == "draining" {
1607
        return Some("is draining".into());
1608
    }
1609
    let scope = r.scope();
1610
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1611
        return Some("serves other repositories".into());
1612
    }
1613
    let tiers = r.tier_list();
1614
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1615
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1616
    }
1617
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1618
        return Some(format!("is {}; the job needs {w}", r.arch));
1619
    }
1620
    // A job with no image (the WASM tier) needs none on the runner, as its claim says.
1621
    let fetches = r.fetches_images && fleet.reach.fetchable(r.id, rootfs);
1622
    if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1623
        let why = if !r.fetches_images {
1624
            ""
1625
        } else if fleet.reach.peer_only(rootfs) {
1626
            " (only runners hold it, none in this runner's swarm site: send it to the hub with flow rootfs push)"
1627
        } else {
1628
            " (nor does the hub: send it with flow rootfs push)"
1629
        };
1630
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1631
    }
1632
    let labels = r.label_list();
1633
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1634
        return Some(format!("lacks label {l}"));
1635
    }
1636
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1637
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1638
    }
1639
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1640
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1641
    }
1642
    // The disk it learned it needs (oxygenflow#41).
1643
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1644
        if free < n {
1645
            return Some(format!(
1646
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1647
                mib(free),
1648
                mib(n),
1649
                mib((n - 64) * 4 / 5)
1650
            ));
1651
        }
1652
    }
1653
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1654
        return Some(format!("declined it: {}", d.reason));
1655
    }
1656
    None
1657
}
1658
1659
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1660
/// now (spec §11.4), from what each runner offered at its last claim:
1661
/// empty when one could (it waits its turn or a free slot), else what
1662
/// each one lacks. `declines` are the job's.
1663
#[allow(clippy::too_many_arguments)]
1664
pub fn why_waiting(
1665
    job: &fdb::Job,
1666
    owner: &str,
1667
    repo_id: i64,
1668
    runners: &[db::runners::Runner],
1669
    image_archs: &std::collections::HashMap<String, String>,
1670
    declines: &[fdb::DeclineRow],
1671
    now: i64,
1672
    reach: &crate::images::Reach,
1673
) -> String {
1674
    let fleet = Fleet { image_archs, reach, now };
1675
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1676
    if online.is_empty() {
1677
        return "no runner is online".into();
1678
    }
1679
    let mut why = Vec::new();
1680
    for r in &online {
1681
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
1682
            None => return String::new(),
1683
            Some(w) => why.push(format!("{} {w}", r.name)),
1684
        }
1685
    }
1686
    let more = why.len().saturating_sub(4);
1687
    why.truncate(4);
1688
    let mut text = format!("no runner can take it: {}", why.join("; "));
1689
    if more > 0 {
1690
        text.push_str(&format!("; and {more} more"));
1691
    }
1692
    text
1693
}
1694
1695
/// Why a job that some runner could take still waits (oxygenhub#259): the
1696
/// runners that could take it are all busy, or have a free slot without
1697
/// the room for it now, and how many ready jobs come first (a higher
1698
/// priority, or the same one and older).
1699
#[allow(clippy::too_many_arguments)]
1700
pub fn why_still_waiting(
1701
    job: &fdb::Job,
1702
    owner: &str,
1703
    repo_id: i64,
1704
    runners: &[db::runners::Runner],
1705
    fleet: (&std::collections::HashMap<String, String>, &crate::images::Reach, i64),
1706
    declines: &[fdb::DeclineRow],
1707
    running: &std::collections::HashMap<i64, i64>,
1708
    ahead: usize,
1709
    behind_urgent: &[String],
1710
) -> String {
1711
    let fleet = Fleet { image_archs: fleet.0, reach: fleet.1, now: fleet.2 };
1712
    let mut busy = Vec::new();
1713
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
1714
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
1715
            continue;
1716
        }
1717
        let n = running.get(&r.id).copied().unwrap_or(0);
1718
        let slots = r.max_jobs.max(1);
1719
        if n >= slots {
1720
            busy.push(format!("{} busy ({n} of {slots})", r.name));
1721
        } else {
1722
            busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory, CPUs or disk for it now", r.name));
1723
        }
1724
    }
1725
    let mut parts = Vec::new();
1726
    if !busy.is_empty() {
1727
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
1728
    }
1729
    if ahead > 0 {
1730
        let after = match behind_urgent {
1731
            [] => String::new(),
1732
            [one] => format!(" (after expedited run {one})"),
1733
            many => format!(" (after expedited runs {})", many.join(", ")),
1734
        };
1735
        parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" }));
1736
    }
1737
    parts.join("; ")
1738
}
1739
1740
/// How long a job is held for a better runner before any runner that fits
1741
/// takes it (oxygenflow#42).
1742
const BETTER_HOLD_MS: i64 = 15_000;
1743
/// A runner counts as there to take a job when it asked this recently.
1744
const POLLING_MS: i64 = 10_000;
1745
1746
/// When each job was first passed over for a better runner.
1747
/// Keyed by hub (tests run several in one process) and job.
1748
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1749
1750
/// The build cache slots each runner said at its last claim it holds
1751
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1752
/// runner; a restart forgets them until each runner's next claim.
1753
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1754
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1755
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1756
1757
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1758
/// it did not say); `None` when it holds none there.
1759
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1760
    CACHES
1761
        .lock()
1762
        .unwrap_or_else(|p| p.into_inner())
1763
        .as_ref()
1764
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1765
        .and_then(|held| held.get(slot).copied())
1766
}
1767
1768
/// A copy of a build cache kept at least this much later is the fresher
1769
/// (oxygenflow#54): closer than that, the two are as good as each other.
1770
const FRESHER_MS: i64 = 60_000;
1771
1772
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1773
struct Fit {
1774
    /// When it last kept the job's build cache, if it holds it.
1775
    cache: Option<i64>,
1776
    image: bool,
1777
    speed: i64,
1778
}
1779
1780
impl Fit {
1781
    /// Whether this fit is better than `other`: holding the job's build
1782
    /// cache counts most (a warm build beats any image fetch), and of two
1783
    /// that hold it the fresher copy; then holding the image, then speed.
1784
    fn beats(&self, other: &Fit) -> bool {
1785
        match (self.cache, other.cache) {
1786
            (Some(_), None) => return true,
1787
            (None, Some(_)) => return false,
1788
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1789
            _ => {}
1790
        }
1791
        (self.image, self.speed) > (other.image, other.speed)
1792
    }
1793
}
1794
1795
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1796
/// `slot`.
1797
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1798
    Fit {
1799
        cache: cache_kept(hub, r.id, slot),
1800
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1801
        speed: r.speed_class.unwrap_or(0),
1802
    }
1803
}
1804
1805
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1806
fn ago(ms: i64) -> String {
1807
    let s = ms.max(0) / 1000;
1808
    match s {
1809
        0..=59 => format!("{s} s"),
1810
        60..=3599 => format!("{} min", s / 60),
1811
        3600..=172_799 => format!("{} h", s / 3600),
1812
        _ => format!("{} days", s / 86_400),
1813
    }
1814
}
1815
1816
fn rootfs_of(job: &fdb::Job) -> String {
1817
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1818
}
1819
1820
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1821
fn mib(m: i64) -> String {
1822
    if m >= 1024 {
1823
        format!("{:.1} GiB", m as f64 / 1024.0)
1824
    } else {
1825
        format!("{m} MiB")
1826
    }
1827
}
1828
1829
/// One placement pass: say why each ready queued job waits.
1830
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1831
    let ready = fdb::ready_queued(&app.db).await?;
1832
    if ready.is_empty() {
1833
        return Ok(());
1834
    }
1835
    let runners = db::runners::list(&app.db).await?;
1836
    let archs = fdb::image_archs(&app.db).await?;
1837
    let held = crate::images::reach(app).await?;
1838
    let now = db::now_ms();
1839
    let ranks = fdb::queued_ranks(&app.db).await?;
1840
    let running = fdb::running_by_runner(&app.db).await?;
1841
    // Claim order, roughly: a better rank first, then the older job.
1842
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
1843
    let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect();
1844
    // Expedited runs (rank 0) by name, for the jobs they moved down (#291).
1845
    let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new();
1846
    for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) {
1847
        if urgent.contains_key(&j.run_id) {
1848
            continue;
1849
        }
1850
        if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? {
1851
            if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1852
                urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number));
1853
            }
1854
        }
1855
    }
1856
    // Runs whose waiting notes changed: their pages hear of it now.
1857
    let mut changed = std::collections::BTreeSet::new();
1858
    for (job, owner, repo_id) in ready {
1859
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1860
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1861
        if why.is_empty() {
1862
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
1863
            let ahead = order.iter().filter(|o| **o < mine).count();
1864
            let mut behind: Vec<String> = Vec::new();
1865
            if mine.0 > 0 {
1866
                for o in order.iter().filter(|o| o.0 == 0 && **o < mine) {
1867
                    let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue };
1868
                    if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) {
1869
                        behind.push(label.clone());
1870
                    }
1871
                }
1872
            }
1873
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind);
1874
        }
1875
        if why == job.waiting {
1876
            continue;
1877
        }
1878
        fdb::set_waiting(&app.db, job.id, &why).await?;
1879
        changed.insert(job.run_id);
1880
        // The job's check says it too, so a proposal shows why it is stuck
1881
        // (oxygenhub#28); only when the reason changes in kind, not in its
1882
        // counts (#259: a queue position moves every pass, and each check
1883
        // report reaches webhooks).
1884
        let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>();
1885
        if kind(&why) == kind(&job.waiting) {
1886
            continue;
1887
        }
1888
        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?) {
1889
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1890
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1891
        }
1892
    }
1893
    for run_id in changed {
1894
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
1895
    }
1896
    Ok(())
1897
}
1898
1899
/// Run a placement pass every 15 seconds.
1900
pub async fn placement(app: Shared) {
1901
    loop {
1902
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1903
        if let Err(e) = explain_waits(&app).await {
1904
            tracing::warn!("flow: placement pass: {e}");
1905
        }
1906
    }
1907
}
1908
1909
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1910
pub(crate) async fn pins_in(
1911
    app: &Shared,
1912
    name: crate::repos::RepoName,
1913
    heads: Vec<oxy_core::hash::ChangeHash>,
1914
) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> {
1915
    app.repos
1916
        .read(&name, move |repo, cache| {
1917
            let loaded = cache.load(repo, &heads)?;
1918
            let mut out = Vec::new();
1919
            for (path, f) in &loaded.snapshot.files {
1920
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1921
                    continue;
1922
                }
1923
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1924
                    if let Some(b) = blobs.first() {
1925
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1926
                            .read_blob(b)
1927
                            .map_err(crate::repos::RepoError::Oxy)
1928
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1929
                        {
1930
                            out.push((path.clone(), id, heads));
1931
                        }
1932
                    }
1933
                }
1934
            }
1935
            Ok(out)
1936
        })
1937
        .await
1938
        .map_err(|e| e.to_string())
1939
}
1940
1941
/// How deep subrepos of subrepos are followed.
1942
const MAX_SUBREPO_DEPTH: usize = 3;
1943
1944
/// The Oxygen subrepos the run's state pins, and those their states pin in
1945
/// turn (parents first, paths joined), that live on this hub and that the
1946
/// run's pusher may read: what the job's workspace needs beside its own
1947
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1948
/// the third list says why, for the job's log.
1949
async fn subrepo_inputs(
1950
    app: &Shared,
1951
    row: &db::repos::RepoRow,
1952
    run: &fdb::Run,
1953
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1954
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1955
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1956
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1957
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1958
    let mut inputs = Vec::new();
1959
    let mut ids = Vec::new();
1960
    let mut left_out = Vec::new();
1961
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1962
    // (path prefix, repository, heads, depth), parents before children.
1963
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1964
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1965
        if depth == MAX_SUBREPO_DEPTH {
1966
            continue;
1967
        }
1968
        for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() {
1969
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1970
            let mut hosted = false;
1971
            for other in &rows {
1972
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1973
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1974
                if !matches {
1975
                    continue;
1976
                }
1977
                hosted = true;
1978
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1979
                // A Diffusion bump's run reads the upstream it bumps: the one
1980
                // the consumer pins (someone who may write it committed the
1981
                // pin), and no other (#150).
1982
                let diffused = run.actor == crate::diffusion::ACCOUNT
1983
                    && run.bookmark.starts_with("diffusion/")
1984
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id = $2")
1985
                        .bind(run.repo_id)
1986
                        .bind(other.id)
1987
                        .fetch_one(&app.db)
1988
                        .await
1989
                        .unwrap_or(0)
1990
                        > 0;
1991
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1992
                    inputs.push(flow_core::api::SubrepoInput {
1993
                        path: path.clone(),
1994
                        repo: format!("{}/{}", other.owner, other.name),
1995
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1996
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1997
                    });
1998
                    if !ids.contains(&other.id) {
1999
                        ids.push(other.id);
2000
                    }
2001
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
2002
                } else {
2003
                    left_out.push(format!(
2004
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
2005
                        other.owner, other.name
2006
                    ));
2007
                }
2008
                break;
2009
            }
2010
            if !hosted {
2011
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
2012
            }
2013
        }
2014
    }
2015
    (inputs, ids, left_out)
2016
}
2017
2018
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
2019
    match fdb::job(&app.db, id).await? {
2020
        Some(j) if j.status == "running" => Ok(j),
2021
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
2022
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
2023
    }
2024
}
2025
2026
/// `POST /api/v1/flow/jobs/{id}/log`.
2027
pub async fn log(
2028
    State(app): State<Shared>,
2029
    headers: HeaderMap,
2030
    Path(id): Path<i64>,
2031
    Json(mut batch): Json<LogBatch>,
2032
) -> Result<StatusCode, ApiError> {
2033
    runner_auth(&app, &headers).await?;
2034
    let job = running_job(&app, id).await?;
2035
    // An empty batch asks only whether the job still runs: a cancelled
2036
    // one answers 409 and its runner stops it (oxygenflow#32).
2037
    if batch.records.is_empty() {
2038
        return Ok(StatusCode::NO_CONTENT);
2039
    }
2040
    // A secret's value never reaches the stored log (#107).
2041
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
2042
        Ok(spec) if !spec.secrets.is_empty() => {
2043
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
2044
            let row = match run {
2045
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
2046
                None => None,
2047
            };
2048
            match row {
2049
                Some(row) => {
2050
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
2051
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
2052
                }
2053
                None => vec![],
2054
            }
2055
        }
2056
        _ => vec![],
2057
    };
2058
    let kid = app.oidc.get().map(|i| i.kid.clone());
2059
    for r in &mut batch.records {
2060
        if !values.is_empty() {
2061
            r.text = crate::secrets::mask(&r.text, &values);
2062
        }
2063
        if let Some(kid) = &kid {
2064
            r.text = crate::oidc::mask(&r.text, kid);
2065
        }
2066
        if r.text.len() > MAX_RECORD_TEXT {
2067
            let mut cut = MAX_RECORD_TEXT;
2068
            while !r.text.is_char_boundary(cut) {
2069
                cut -= 1;
2070
            }
2071
            r.text.truncate(cut);
2072
        }
2073
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
2074
            r.stream = "console".into();
2075
        }
2076
    }
2077
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
2078
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
2079
        tracing::info!("flow: job {} reached the log limit", job.id);
2080
    }
2081
    Ok(StatusCode::NO_CONTENT)
2082
}
2083
2084
/// Largest artifact the hub keeps; the runner refuses larger ones first.
2085
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
2086
2087
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
2088
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
2089
/// repository. Answers `{"blob"}`.
2090
pub async fn artifact(
2091
    State(app): State<Shared>,
2092
    headers: HeaderMap,
2093
    Path((id, name)): Path<(i64, String)>,
2094
    body: axum::body::Bytes,
2095
) -> Result<Response, ApiError> {
2096
    runner_auth(&app, &headers).await?;
2097
    let job = running_job(&app, id).await?;
2098
    let spec: flow_core::Job =
2099
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2100
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
2101
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
2102
    };
2103
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2104
    let row =
2105
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2106
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2107
    // A report's artifact is its manifest (#127): checked, and its files
2108
    // found in the store, before it is kept.
2109
    let report = match out.kind {
2110
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
2111
        flow_core::OutputKind::Artifact => None,
2112
    };
2113
    let content = blake3::hash(&body).to_hex().to_string();
2114
    let size = body.len() as i64;
2115
    let artifact = report.is_none();
2116
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
2117
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
2118
    let blob = app
2119
        .repos
2120
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2121
        .await
2122
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
2123
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
2124
    fdb::add_artifact(&app.db, &a).await?;
2125
    if let Some((manifest, declaration)) = report {
2126
        let origin = crate::reports::Origin {
2127
            repo_id: row.id,
2128
            run_id: run.id,
2129
            run_number: run.number,
2130
            job_id: job.id,
2131
            job: &job.name,
2132
            bookmark: &run.bookmark,
2133
            state: &run.state,
2134
        };
2135
        let said = match declaration {
2136
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
2137
                Ok(id) => {
2138
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
2139
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
2140
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
2141
                }
2142
                Err(e) => format!("report {}: not indexed: {e}", a.name),
2143
            },
2144
            Err(why) => {
2145
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
2146
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
2147
                format!("report {}: report.toml: {why}", a.name)
2148
            }
2149
        };
2150
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2151
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2152
    }
2153
    // A coverage report (#113) is read now; what it says, or why it could
2154
    // not be read, goes in the job's log. It never fails the upload.
2155
    if let Some(bytes) = report_bytes {
2156
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
2157
            Ok(text) | Err(text) => text,
2158
        };
2159
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2160
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2161
    }
2162
    // A test report (#114) likewise.
2163
    if let Some(bytes) = junit_bytes {
2164
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
2165
            Ok(text) | Err(text) => text,
2166
        };
2167
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2168
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2169
    }
2170
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
2171
}
2172
2173
/// A check per metric with a threshold (#127, docs/reports.md §5), named
2174
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
2175
/// `fail_above` or `fail_below`, which a proposal may require.
2176
async fn report_threshold_checks(
2177
    app: &Shared,
2178
    row: &db::repos::RepoRow,
2179
    run: &fdb::Run,
2180
    name: &str,
2181
    id: i64,
2182
    metrics: &[crate::reports::Metric],
2183
) {
2184
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
2185
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
2186
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
2187
        let above = m.fail_above.filter(|a| m.value > *a);
2188
        let below = m.fail_below.filter(|b| m.value < *b);
2189
        let (status, summary) = match (above, below) {
2190
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
2191
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
2192
            _ => {
2193
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
2194
                    .into_iter()
2195
                    .flatten()
2196
                    .collect();
2197
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
2198
            }
2199
        };
2200
        let check = format!("report/{name}/{}", m.key);
2201
        if let Ok(c) = db::hooks::upsert_check(
2202
            &app.db,
2203
            row.id,
2204
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
2205
        )
2206
        .await
2207
        {
2208
            let api = crate::api::check_of(&c);
2209
            app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2210
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2211
        }
2212
    }
2213
}
2214
2215
/// Check report manifest `body` (#127) and that the repository holds every
2216
/// file it names; answers it with the report's declaration, or why that
2217
/// could not be read.
2218
async fn report_manifest(
2219
    app: &Shared,
2220
    repo: &RepoName,
2221
    name: &str,
2222
    body: &[u8],
2223
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
2224
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
2225
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
2226
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
2227
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
2228
    let found = app
2229
        .repos
2230
        .read(repo, move |r, _| {
2231
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
2232
                return Ok(Err(path.clone()));
2233
            }
2234
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
2235
        })
2236
        .await
2237
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
2238
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
2239
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
2240
    Ok((manifest, declaration))
2241
}
2242
2243
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
2244
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
2245
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
2246
/// the files follows as the report's artifact.
2247
pub async fn report_file(
2248
    State(app): State<Shared>,
2249
    headers: HeaderMap,
2250
    Path((id, name)): Path<(i64, String)>,
2251
    body: axum::body::Bytes,
2252
) -> Result<Response, ApiError> {
2253
    runner_auth(&app, &headers).await?;
2254
    let job = running_job(&app, id).await?;
2255
    let spec: flow_core::Job =
2256
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2257
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
2258
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
2259
    }
2260
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
2261
    if !crate::reports::path_ok(path) {
2262
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
2263
    }
2264
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2265
    let row =
2266
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2267
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2268
    let blob = app
2269
        .repos
2270
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2271
        .await
2272
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
2273
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
2274
}
2275
2276
/// Parse and keep a job's JUnit report: what it says, or why not.
2277
async fn read_junit(
2278
    app: &Shared,
2279
    row: &db::repos::RepoRow,
2280
    run: &fdb::Run,
2281
    job: &fdb::Job,
2282
    blob: &str,
2283
    bytes: axum::body::Bytes,
2284
) -> Result<String, String> {
2285
    if bytes.len() > crate::junit::MAX_REPORT {
2286
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
2287
    }
2288
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
2289
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
2290
        .await
2291
        .map_err(|e| format!("tests: {e}"))?
2292
        .map_err(|e| format!("tests: {e}"))?;
2293
    let origin = crate::junit::Origin {
2294
        repo_id: row.id,
2295
        run_id: run.id,
2296
        run_number: run.number,
2297
        job_id: job.id,
2298
        job: &job.name,
2299
        bookmark: &run.bookmark,
2300
        state: &run.state,
2301
        blob,
2302
    };
2303
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
2304
    let r = crate::junit::for_run(&app.db, run.id)
2305
        .await
2306
        .map_err(|e| format!("tests: {e}"))?
2307
        .into_iter()
2308
        .find(|r| r.job_id == job.id)
2309
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
2310
    // The `tests` check on the run's state: what fails, and which of those
2311
    // passed in the same job's latest report on the default bookmark, so a
2312
    // proposal names what it broke.
2313
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
2314
    let newly = match &base {
2315
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
2316
        _ => vec![],
2317
    };
2318
    let (status, summary) = if r.broken() == 0 {
2319
        ("passed", format!("{} passed", r.passed))
2320
    } else if let Some(b) = &base {
2321
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
2322
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
2323
        let new = if newly.is_empty() {
2324
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
2325
        } else {
2326
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
2327
        };
2328
        ("failed", format!("{} failing; {new}", r.broken()))
2329
    } else {
2330
        ("failed", format!("{} failing", r.broken()))
2331
    };
2332
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
2333
    if let Ok(c) = db::hooks::upsert_check(
2334
        &app.db,
2335
        row.id,
2336
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
2337
    )
2338
    .await
2339
    {
2340
        let api = crate::api::check_of(&c);
2341
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2342
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2343
    }
2344
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
2345
}
2346
2347
/// Parse and keep a job's coverage report: what it says, or why not.
2348
async fn read_coverage(
2349
    app: &Shared,
2350
    repo: &RepoName,
2351
    row: &db::repos::RepoRow,
2352
    run: &fdb::Run,
2353
    job: &fdb::Job,
2354
    blob: &str,
2355
    bytes: axum::body::Bytes,
2356
) -> Result<String, String> {
2357
    if bytes.len() > crate::coverage::MAX_REPORT {
2358
        return Err(format!(
2359
            "coverage: the report is {} MiB; at most {} MiB is read",
2360
            bytes.len() >> 20,
2361
            crate::coverage::MAX_REPORT >> 20
2362
        ));
2363
    }
2364
    let text = String::from_utf8(bytes.to_vec())
2365
        .map_err(|_| "coverage: the report is not text (LCOV, Cobertura XML or JaCoCo XML is expected)".to_string())?;
2366
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
2367
        .await
2368
        .map_err(|e| format!("coverage: {e}"))?
2369
        .map_err(|e| format!("coverage: {e}"))?;
2370
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
2371
    let in_state: std::collections::HashSet<String> = app
2372
        .repos
2373
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
2374
        .await
2375
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
2376
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
2377
    let origin = crate::coverage::Origin {
2378
        repo_id: row.id,
2379
        run_id: Some(run.id),
2380
        run_number: run.number,
2381
        job_id: Some(job.id),
2382
        uploader: "",
2383
        job: &job.name,
2384
        bookmark: &run.bookmark,
2385
        state: &run.state,
2386
        heads: &heads,
2387
        blob,
2388
        format,
2389
    };
2390
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
2391
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
2392
        return Err("coverage: the report was not kept".into());
2393
    };
2394
    // The `coverage` check on the run's state (#113): against the default
2395
    // bookmark, under the repository's rules.
2396
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
2397
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
2398
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
2399
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
2400
    if let Ok(c) = db::hooks::upsert_check(
2401
        &app.db,
2402
        row.id,
2403
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
2404
    )
2405
    .await
2406
    {
2407
        let api = crate::api::check_of(&c);
2408
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2409
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2410
    }
2411
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
2412
    let dropped =
2413
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
2414
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
2415
}
2416
2417
/// `POST /api/v1/flow/jobs/{id}/finish`.
2418
pub async fn finish(
2419
    State(app): State<Shared>,
2420
    headers: HeaderMap,
2421
    Path(id): Path<i64>,
2422
    Json(f): Json<Finish>,
2423
) -> Result<StatusCode, ApiError> {
2424
    let me = runner_auth(&app, &headers).await?;
2425
    let job = running_job(&app, id).await?;
2426
    let status = match (f.exit_code, &f.error) {
2427
        (Some(0), None) => "passed",
2428
        (Some(_), _) => "failed",
2429
        (None, _) => "error",
2430
    };
2431
    let (receipt, receipt_hash) = match &f.receipt {
2432
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
2433
            // A passing job's receipt names exactly the artifacts uploaded,
2434
            // in name order; a failing one's names none, though it may have
2435
            // kept `when "always"` artifacts (oxygenflow#37).
2436
            let kept: Vec<String> =
2437
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
2438
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
2439
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
2440
            }
2441
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
2442
        }
2443
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
2444
        None => (String::new(), String::new()),
2445
    };
2446
    let error = f.error.clone().unwrap_or_default();
2447
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
2448
    fdb::set_phases(&app.db, job.id, &f.phases).await?;
2449
    let changed = fdb::finish(
2450
        &app.db,
2451
        job.id,
2452
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
2453
    )
2454
    .await?;
2455
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
2456
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
2457
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
2458
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
2459
                    Ok((attest, signer)) => (attest, signer, String::new()),
2460
                    Err(e) => {
2461
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
2462
                        (String::new(), String::new(), e)
2463
                    }
2464
                };
2465
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
2466
            }
2467
        }
2468
    }
2469
    report_changed(&app, job.run_id, changed).await?;
2470
    Ok(StatusCode::NO_CONTENT)
2471
}
2472
2473
/// Report the checks of jobs whose status changed.
2474
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
2475
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
2476
        return Ok(());
2477
    };
2478
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
2479
        return Ok(());
2480
    };
2481
    let mut errored_on: Vec<i64> = Vec::new();
2482
    let some_passed = changed.iter().any(|j| j.status == "passed");
2483
    if !changed.is_empty() {
2484
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
2485
    }
2486
    for j in changed {
2487
        let summary = match j.status.as_str() {
2488
            "passed" => match (j.reused_from(), j.reused_repo()) {
2489
                (Some(n), Some(r)) => format!("passed in {r} run #{n} on this state, reused"),
2490
                (Some(n), None) => format!("passed in run #{n} on this state, reused"),
2491
                (None, _) => format!("passed in {}", duration(&j)),
2492
            },
2493
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
2494
            "skipped" => "skipped: a dependency did not pass".into(),
2495
            "cancelled" => j.error.clone(),
2496
            _ => format!("could not run: {}", j.error),
2497
        };
2498
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
2499
        if j.status == "error" {
2500
            if let Some(r) = runner_of(app, j.id).await {
2501
                errored_on.push(r);
2502
            }
2503
        }
2504
    }
2505
    errored_on.dedup();
2506
    for r in errored_on {
2507
        drain_if_erroring(app, r).await;
2508
    }
2509
    if some_passed {
2510
        tell_approvers(app, &row, &run).await;
2511
    }
2512
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2513
        if done.status == "failed" && !done.live() {
2514
            tell_failed(app, &row, &done).await;
2515
        }
2516
        // A pass after a failure on the same bookmark: fixed (#183).
2517
        if done.status == "passed" {
2518
            let before: Option<String> = sqlx::query_scalar(
2519
                "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND id < $3 AND status IN ('passed', 'failed')
2520
                 ORDER BY id DESC LIMIT 1",
2521
            )
2522
            .bind(done.repo_id)
2523
            .bind(&done.bookmark)
2524
            .bind(done.id)
2525
            .fetch_optional(&app.db)
2526
            .await?;
2527
            // Or this very run failed and a job of it was run again.
2528
            let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number);
2529
            let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = $1")
2530
                .bind(&url)
2531
                .fetch_one(&app.db)
2532
                .await?;
2533
            if before.as_deref() == Some("failed") || failed_here > 0 {
2534
                tell_fixed(app, &row, &done).await;
2535
            }
2536
        }
2537
    }
2538
    // A passing run of an upstream's default bookmark: consumers following
2539
    // it when green are bumped (Diffusion, #148).
2540
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2541
        if done.status == "passed" && done.bookmark == row.default_bookmark {
2542
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
2543
            let (app, row) = (app.clone(), row.clone());
2544
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
2545
        }
2546
        // A bump's run passed: a consumer that lands its bumps itself may
2547
        // land it now (docs/diffusion.md §4.2).
2548
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
2549
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
2550
        }
2551
    }
2552
    Ok(())
2553
}
2554
2555
/// Make run `run` urgent (oxygenhub#244), saying why: refused without a
2556
/// reason, or when the owner already has as many urgent runs going as the
2557
/// site allows (Admin > Runners > Settings). Audited; every queued job's
2558
/// place is worked out again at once (#291). For the run page and the API.
2559
pub async fn expedite(
2560
    app: &Shared,
2561
    row: &db::repos::RepoRow,
2562
    run: &fdb::Run,
2563
    me: &db::accounts::Account,
2564
    reason: &str,
2565
) -> Result<(), String> {
2566
    let reason: String = reason.chars().filter(|c| !c.is_control()).take(200).collect();
2567
    let reason = reason.trim();
2568
    if reason.is_empty() {
2569
        return Err("Say why the run is urgent.".into());
2570
    }
2571
    let owner = &row.owner;
2572
    // A cap on one owner's urgent runs at once, so urgent can't starve
2573
    // everyone else.
2574
    let cap: i64 = db::accounts::setting(&app.db, "flow_expedite_cap").await.ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(2);
2575
    let live = fdb::expedited_live(&app.db, owner).await.map_err(|e| e.to_string())?;
2576
    if live >= cap {
2577
        return Err(format!("{owner} already has {live} expedited run(s) going, the most at once ({cap}); wait for one to end."));
2578
    }
2579
    let note = format!("expedited by {}: {reason}", me.username);
2580
    if fdb::expedite(&app.db, run.id, &note).await.map_err(|e| e.to_string())? {
2581
        db::accounts::audit(&app.db, Some(me), "flow.expedite", &format!("{owner}/{}", row.name), &format!("#{}: {reason}", run.number))
2582
            .await
2583
            .ok();
2584
        // This run's page and the pages of the runs it moved down hear of it.
2585
        if let Err(e) = explain_waits(app).await {
2586
            tracing::warn!("flow: placing after an expedite: {e}");
2587
        }
2588
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
2589
    }
2590
    Ok(())
2591
}
2592
2593
/// The account OxygenFlow's notices come from, made on first use with no
2594
/// way to sign in.
2595
pub const ACCOUNT: &str = "oxygenflow";
2596
2597
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
2598
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
2599
        return Some(a);
2600
    }
2601
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
2602
}
2603
2604
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
2605
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = $1").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
2606
}
2607
2608
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
2609
/// are told, in their inboxes and by email as they chose, naming the jobs
2610
/// that did not pass.
2611
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2612
    let Some(me) = flow_account(&app.db).await else { return };
2613
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
2614
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
2615
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2616
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2617
    crate::notify::deliver(
2618
        app,
2619
        crate::notify::Event {
2620
            kind: "flow.failed",
2621
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
2622
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2623
            actor: &me,
2624
            repo_id: row.id,
2625
            thread: None,
2626
            mention_text: "",
2627
            direct: pusher.into_iter().map(|a| a.id).collect(),
2628
        },
2629
    )
2630
    .await;
2631
}
2632
2633
/// A run that passes after its bookmark's last one failed (oxygenhub#183):
2634
/// its pusher and the repository's watchers hear it is green again.
2635
async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2636
    let Some(me) = flow_account(&app.db).await else { return };
2637
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2638
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2639
    crate::notify::deliver(
2640
        app,
2641
        crate::notify::Event {
2642
            kind: "flow.fixed",
2643
            title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number),
2644
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2645
            actor: &me,
2646
            repo_id: row.id,
2647
            thread: None,
2648
            mention_text: "",
2649
            direct: pusher.into_iter().map(|a| a.id).collect(),
2650
        },
2651
    )
2652
    .await;
2653
}
2654
2655
/// A runner whose last jobs all ended in infrastructure errors is drained,
2656
/// so it stops taking jobs it fails before running them, and the
2657
/// administrators are told (oxygenflow#30).
2658
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
2659
    let n = app.config.flow.drain_after_errors;
2660
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
2661
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
2662
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
2663
        return;
2664
    }
2665
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
2666
    let Some(me) = flow_account(&app.db).await else { return };
2667
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default();
2668
    crate::notify::deliver(
2669
        app,
2670
        crate::notify::Event {
2671
            kind: "flow.runner_drained",
2672
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
2673
            url: format!("/admin/runners/{runner_id}"),
2674
            actor: &me,
2675
            repo_id: 0,
2676
            thread: None,
2677
            mention_text: "",
2678
            direct: admins,
2679
        },
2680
    )
2681
    .await;
2682
}
2683
2684
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
2685
/// Settings, else from `hub.toml`.
2686
pub struct Limits {
2687
    /// For a job whose pipeline names none.
2688
    pub default_s: u64,
2689
    /// The most a pipeline may ask for.
2690
    pub max_s: u64,
2691
    /// Grace past a job's limit before the hub gives it up: a runner stops
2692
    /// a job at its limit and reports it within moments, so one still
2693
    /// running this long after is one the runner no longer reports (it was
2694
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
2695
    pub grace_ms: i64,
2696
}
2697
2698
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
2699
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
2700
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
2701
2702
pub async fn limits(app: &Shared) -> Limits {
2703
    let minutes = |key: &'static str| async move {
2704
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
2705
    };
2706
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
2707
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
2708
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
2709
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
2710
}
2711
2712
/// End every job still running past its time limit plus the grace:
2713
/// it is marked an error with the reason, the jobs that depended on it are
2714
/// skipped and its run settled, as when a runner reports an error.
2715
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
2716
    let limits = limits(app).await;
2717
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
2718
    let n = lost.len();
2719
    for job in lost {
2720
        let error = format!(
2721
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
2722
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
2723
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
2724
        );
2725
        let changed =
2726
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2727
                .await?;
2728
        tracing::warn!("flow: job {} {error}", job.id);
2729
        report_changed(app, job.run_id, changed).await?;
2730
    }
2731
    Ok(n)
2732
}
2733
2734
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
2735
/// it restarted since claiming them, or never received the claim. A
2736
/// runner claims one job at a time, so a job it holds is always listed.
2737
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
2738
    for job in fdb::running_on(&app.db, runner.id).await? {
2739
        if holding.contains(&job.id) {
2740
            continue;
2741
        }
2742
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
2743
        let changed =
2744
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2745
                .await?;
2746
        tracing::warn!("flow: job {} {error}", job.id);
2747
        report_changed(app, job.run_id, changed).await?;
2748
    }
2749
    Ok(())
2750
}
2751
2752
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2753
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2754
/// with a `schedule` line due then. Returns how many runs started.
2755
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2756
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2757
    let mut started = 0;
2758
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2759
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2760
        let b = row.default_bookmark.clone();
2761
        let found = app
2762
            .repos
2763
            .read(&repo, move |r, _| {
2764
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2765
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2766
            })
2767
            .await;
2768
        let Ok(Some((state, heads))) = found else { continue };
2769
        if heads.is_empty() {
2770
            continue;
2771
        }
2772
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
2773
            Ok(Some(_)) => started += 1,
2774
            Ok(None) => {}
2775
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2776
        }
2777
    }
2778
    started
2779
}
2780
2781
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2782
pub fn when_now() -> flow_core::pipeline::When {
2783
    let now = time::OffsetDateTime::now_utc();
2784
    flow_core::pipeline::When {
2785
        minute: now.minute() as u32,
2786
        hour: now.hour() as u32,
2787
        day: now.day() as u32,
2788
        month: u8::from(now.month()) as u32,
2789
        weekday: now.weekday().number_days_from_sunday() as u32,
2790
    }
2791
}
2792
2793
/// Start the scheduled runs due each minute, once a minute.
2794
pub async fn scheduler(app: Shared) {
2795
    let mut last = None;
2796
    loop {
2797
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2798
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2799
        if last == Some(minute) {
2800
            continue;
2801
        }
2802
        last = Some(minute);
2803
        let n = run_due(&app, when_now()).await;
2804
        if n > 0 {
2805
            tracing::info!("flow: {n} scheduled run(s) started");
2806
        }
2807
    }
2808
}
2809
2810
/// Look for lost jobs once a minute.
2811
pub async fn reaper(app: Shared) {
2812
    loop {
2813
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2814
        if let Err(e) = requeue_lapsed(&app).await {
2815
            tracing::warn!("flow: looking for lapsed leases: {e}");
2816
        }
2817
        if let Err(e) = reap_lost_jobs(&app).await {
2818
            tracing::warn!("flow: looking for lost jobs: {e}");
2819
        }
2820
        match expire_approvals(&app, db::now_ms()).await {
2821
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2822
            Ok(_) => {}
2823
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2824
        }
2825
    }
2826
}
2827
2828
/// Check a runner's signed receipt for a job of `run` and keep it in the
2829
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2830
/// name, or why it was not kept. It must be the job's receipt, for the
2831
/// run's state and repository, signed by the key the runner's token first
2832
/// signed with.
2833
pub async fn keep_receipt(
2834
    app: &Shared,
2835
    runner: &db::runners::Runner,
2836
    row: &db::repos::RepoRow,
2837
    run: &fdb::Run,
2838
    receipt: &flow_core::Receipt,
2839
    signed_hex: &str,
2840
) -> Result<(String, String), String> {
2841
    use oxy_core::receipt::SignedReceipt;
2842
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2843
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2844
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2845
    if sr.receipt.hash().0 != receipt.hash() {
2846
        return Err("it signs another receipt than the job's".into());
2847
    }
2848
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2849
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2850
    if sr.state() != state {
2851
        return Err("it is for another state than the run's".into());
2852
    }
2853
    let signer = match &sr.certificate {
2854
        // Signed by a short-lived key this hub certified for this job
2855
        // (keyless, #241): the certificate stands for the runner's key.
2856
        Some(c) => {
2857
            let cert = &c.cert.cert;
2858
            if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key {
2859
                return Err("its certificate was not issued by this hub".into());
2860
            }
2861
            let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark);
2862
            if cert.subject != subject {
2863
                return Err(format!("its certificate is for {}, not {subject}", cert.subject));
2864
            }
2865
            if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) {
2866
                return Err("its certificate is for another run or job".into());
2867
            }
2868
            // Now is when this hub accepts it: a time the runner did not choose.
2869
            if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) {
2870
                return Err("its certificate does not cover now: certify the key just before signing the receipt".into());
2871
            }
2872
            format!("{} (short-lived key)", runner.name)
2873
        }
2874
        None => {
2875
            let key = sr.runner.hash().to_hex();
2876
            let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2877
            if bound != key {
2878
                return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2879
            }
2880
            sr.runner.name.clone()
2881
        }
2882
    };
2883
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2884
    let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
2885
    let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer));
2886
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2887
    // The transparency log (#238): the receipt, and a short-lived key's
2888
    // certificate beside it.
2889
    let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }];
2890
    if let Some((c, issuer)) = cert {
2891
        entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] });
2892
    }
2893
    crate::tlog::log(app, entries).await;
2894
    Ok((attest.to_hex(), signer))
2895
}
2896
2897
/// A job's wall time, as the pages show it.
2898
pub fn duration(j: &fdb::Job) -> String {
2899
    match (j.started_ms, j.finished_ms) {
2900
        (Some(a), Some(b)) => human_ms(b - a),
2901
        (Some(a), None) => human_ms(db::now_ms() - a),
2902
        _ => "–".into(),
2903
    }
2904
}
2905
2906
pub fn human_ms(ms: i64) -> String {
2907
    let ms = ms.max(0);
2908
    if ms < 1000 {
2909
        format!("{ms} ms")
2910
    } else if ms < 60_000 {
2911
        format!("{:.1} s", ms as f64 / 1000.0)
2912
    } else {
2913
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2914
    }
2915
}
2916
2917
/// Where a live run stands among the runners' work: what its viewers may
2918
/// know of it. Jobs of other repositories are named only when those are
2919
/// public; the rest are counted.
2920
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2921
pub struct QueueInfo {
2922
    /// 1-based place of the run's first queued job among all queued jobs.
2923
    pub position: Option<usize>,
2924
    /// Jobs running now, and queued jobs ahead of the run's first one.
2925
    pub running: Vec<String>,
2926
    pub ahead: Vec<String>,
2927
    /// Of those, jobs in repositories the page may not name.
2928
    pub hidden: usize,
2929
}
2930
2931
impl QueueInfo {
2932
    /// "3rd in line" and the like.
2933
    pub fn place(&self) -> Option<String> {
2934
        self.position.map(|n| {
2935
            let suffix = match (n % 10, n % 100) {
2936
                (1, 11) | (2, 12) | (3, 13) => "th",
2937
                (1, _) => "st",
2938
                (2, _) => "nd",
2939
                (3, _) => "rd",
2940
                _ => "th",
2941
            };
2942
            format!("{n}{suffix} in line")
2943
        })
2944
    }
2945
}
2946
2947
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2948
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2949
    let mut info = QueueInfo::default();
2950
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2951
    info.position = first.map(|i| i + 1);
2952
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2953
    let mut queued_seen = 0;
2954
    for e in queue {
2955
        if e.run_id == run_id {
2956
            continue;
2957
        }
2958
        let ahead = match e.status.as_str() {
2959
            "running" => true,
2960
            _ => {
2961
                queued_seen += 1;
2962
                first.is_some_and(|f| queued_seen <= f)
2963
            }
2964
        };
2965
        if !ahead {
2966
            continue;
2967
        }
2968
        if e.repo_id != repo_id && e.visibility != "public" {
2969
            info.hidden += 1;
2970
        } else if e.status == "running" {
2971
            info.running.push(label(e));
2972
        } else {
2973
            info.ahead.push(label(e));
2974
        }
2975
    }
2976
    info
2977
}
2978
2979
#[cfg(test)]
2980
mod queue_tests {
2981
    use super::*;
2982
2983
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2984
        fdb::QueueEntry {
2985
            job_id,
2986
            run_id,
2987
            repo_id,
2988
            owner: "o".into(),
2989
            name: format!("r{repo_id}"),
2990
            visibility: vis.into(),
2991
            run_number: run_id,
2992
            job: job.into(),
2993
            status: status.into(),
2994
            runner: String::new(),
2995
            spec: String::new(),
2996
            sandbox: String::new(),
2997
            started_ms: None,
2998
            priority_rank: 2,
2999
            priority_note: String::new(),
3000
        }
3001
    }
3002
3003
    #[test]
3004
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
3005
        // Running first, then queued in claim order (as `fdb::queue` returns them).
3006
        let q = vec![
3007
            e(1, 10, 1, "public", "build", "running"),
3008
            e(2, 11, 2, "private", "test", "running"),
3009
            e(3, 10, 1, "public", "test", "queued"),
3010
            e(4, 12, 3, "private", "lint", "queued"),
3011
            e(5, 13, 4, "public", "build", "queued"),
3012
            e(6, 13, 4, "public", "test", "queued"),
3013
            e(7, 14, 5, "public", "late", "queued"),
3014
        ];
3015
        let info = queue_info(&q, 4, 13);
3016
        assert_eq!(info.position, Some(3));
3017
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
3018
        assert_eq!(info.running, ["o/r1 #10 build"]);
3019
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
3020
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
3021
        // A private repository's own jobs are named to its viewers.
3022
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
3023
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
3024
        // A run with nothing queued has no place.
3025
        assert_eq!(queue_info(&q, 1, 99).position, None);
3026
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
3027
        assert_eq!(
3028
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
3029
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
3030
        );
3031
    }
3032
}
3033
3034
#[cfg(test)]
3035
mod placement_tests {
3036
    use super::*;
3037
3038
    #[test]
3039
    fn a_waiting_job_says_what_each_runner_lacks() {
3040
        use crate::db::runners::Runner;
3041
        let rootfs = "ab".repeat(32);
3042
        let job = fdb::Job {
3043
            id: 7,
3044
            runtime: "syscall".into(),
3045
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
3046
            spec: serde_json::json!({ "name": "build" }).to_string(),
3047
            ..Default::default()
3048
        };
3049
        let now = db::now_ms();
3050
        let runner = |id: i64, name: &str| Runner {
3051
            id,
3052
            name: name.into(),
3053
            state: "active".into(),
3054
            last_seen_ms: Some(now),
3055
            arch: "x86_64".into(),
3056
            tiers: "microvm,syscall".into(),
3057
            images: serde_json::to_string(&[&rootfs]).unwrap(),
3058
            memory_mib: Some(16384),
3059
            cpus: Some(8),
3060
            ..Default::default()
3061
        };
3062
        let archs = std::collections::HashMap::new();
3063
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
3064
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
3065
        };
3066
        assert_eq!(why(&[], &[]), "no runner is online");
3067
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
3068
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
3069
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
3070
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
3071
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
3072
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
3073
        assert_eq!(
3074
            why(&[microvm_only, small, other, offline], &[]),
3075
            "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"
3076
        );
3077
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
3078
        assert_eq!(
3079
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
3080
            "no runner can take it: narvi is riscv64; the job needs x86_64"
3081
        );
3082
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
3083
        assert_eq!(why(std::slice::from_ref(&without), &[]), "no runner can take it: new does not hold image abababababab");
3084
        // A WASM job has no image: holding none is no reason to wait.
3085
        let wasm = fdb::Job { runtime: "wasm".into(), sandbox: r#"{"memory_mib":256}"#.into(), ..job.clone() };
3086
        let wasm_runner = Runner { tiers: "microvm,wasm".into(), ..without };
3087
        assert_eq!(why_waiting(&wasm, "andy", 1, &[wasm_runner], &archs, &[], now, &Default::default()), "", "it only waits its turn");
3088
        let d = fdb::DeclineRow {
3089
            job_id: 7,
3090
            runner_id: 1,
3091
            runner: "earth-1".into(),
3092
            reason: "only 512 MiB of memory is free here".into(),
3093
            ms: now,
3094
        };
3095
        assert_eq!(
3096
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
3097
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
3098
        );
3099
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
3100
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
3101
    }
3102
}