OXYGENHUB
# e4203e141e90 oxygenhub / crates / hub / src / flow.rs
138.5 KiB · Rust · last changed Checkpoints, so a handed-back job resumes (refs andymccall/oxygenflow#77) 11 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
        Err(problems) => {
748
            let msg = problems.to_string();
749
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
750
        }
751
        Ok(p) => {
752
            let depths = p.depths();
753
            // Jobs out of the push's scope stay out, unless a kept job
754
            // needs them.
755
            // The jobs this run's event starts (`on`), out of those its
756
            // push touched (`scope`); then what they depend on.
757
            let event = match at {
758
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
759
                None => event_of(trigger, bookmark),
760
            };
761
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
762
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
763
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
764
            } else {
765
                // Named jobs (oxygenhub#130): each one this event starts.
766
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
767
                for name in only {
768
                    if !startable.contains(&name.as_str()) {
769
                        let what = if p.jobs.contains_key(name) {
770
                            "is not started by hand (no `on { manual }`)"
771
                        } else {
772
                            "is not in the pipeline"
773
                        };
774
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
775
                    }
776
                }
777
                only.iter().cloned().collect()
778
            };
779
            loop {
780
                let needed: Vec<String> = p
781
                    .order()
782
                    .into_iter()
783
                    .filter(|j| keep.contains(&j.name))
784
                    .flat_map(|j| j.depends_on.iter().cloned())
785
                    .filter(|d| !keep.contains(d))
786
                    .collect();
787
                if needed.is_empty() {
788
                    break;
789
                }
790
                keep.extend(needed);
791
            }
792
            let mut jobs: Vec<fdb::NewJob> = p
793
                .order()
794
                .into_iter()
795
                .filter(|j| keep.contains(&j.name))
796
                .map(|j| {
797
                    let sb = &p.sandboxes[&j.sandbox];
798
                    fdb::NewJob {
799
                        name: j.name.clone(),
800
                        depth: depths[j.name.as_str()] as i64,
801
                        depends: j.depends_on.clone(),
802
                        spec: serde_json::to_string(j).unwrap_or_default(),
803
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
804
                        runtime: sb.runtime.as_str().into(),
805
                        need_disk_mib: None,
806
                        reused: None,
807
                    }
808
                })
809
                .collect();
810
            // What each job needs, learned from its past runs (oxygenflow#41).
811
            for j in &mut jobs {
812
                j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?;
813
            }
814
            if jobs.is_empty() {
815
                // Nothing this event starts (or this push touched).
816
                return Ok(None);
817
            }
818
            // A job that passed on this very state, with the same spec and
819
            // sandbox, is not run again by a push (#265): main after a
820
            // proposal landed it as tested. Never one that publishes, deploys
821
            // or holds secrets, nor one whose artifacts a job of this run
822
            // takes; and not where the repository turned reuse off.
823
            if trigger == "push" && row.flow_reuse {
824
                let consumed: std::collections::HashSet<&str> = p
825
                    .order()
826
                    .into_iter()
827
                    .filter(|j| keep.contains(&j.name))
828
                    .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str()))
829
                    .collect();
830
                for j in &mut jobs {
831
                    let Some(spec) = p.jobs.get(&j.name) else { continue };
832
                    if !reusable(spec) || consumed.contains(j.name.as_str()) {
833
                        continue;
834
                    }
835
                    j.reused =
836
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
837
                }
838
            }
839
            let hash = hex::encode(p.hash());
840
            let id =
841
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
842
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
843
                for j in jobs.iter().filter(|j| j.reused.is_none()) {
844
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
845
                }
846
                // Reused passes (#265) report as any pass does: their checks,
847
                // and a run that passed with them (Diffusion, landing a bump).
848
                if jobs.iter().any(|j| j.reused.is_some()) {
849
                    let reused: Vec<fdb::Job> =
850
                        fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect();
851
                    if let Err(e) = report_changed(app, run.id, reused).await {
852
                        tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number);
853
                    }
854
                }
855
                images_check(app, row, state, run.number, &jobs).await;
856
            }
857
            id
858
        }
859
    };
860
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
861
    if supersedes(trigger) {
862
        supersede(app, row, bookmark, trigger, run.number).await;
863
    }
864
    Ok(Some(run.number))
865
}
866
867
/// The `pipeline/images` check (#137): set failing when an image the run's
868
/// jobs pin is held by neither the hub nor a runner, so a proposal pinning
869
/// one says so before it lands rather than its runs queueing for good;
870
/// passed again by a later run of the state once every image is held.
871
async fn images_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, jobs: &[fdb::NewJob]) {
872
    let mut pinned: Vec<String> = jobs
873
        .iter()
874
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(&j.sandbox).ok())
875
        .filter_map(|v| v["rootfs"].as_str().map(String::from))
876
        .filter(|r| !r.is_empty())
877
        .collect();
878
    pinned.sort();
879
    pinned.dedup();
880
    if pinned.is_empty() {
881
        return;
882
    }
883
    let mut held = crate::images::held(&app.db).await.unwrap_or_default();
884
    // Nobody can be said to lack an image unless some runner is known and
885
    // every one reports what it holds.
886
    let runners: Vec<db::runners::Runner> =
887
        db::runners::list(&app.db).await.unwrap_or_default().into_iter().filter(|r| r.state != "disabled").collect();
888
    if runners.is_empty() || runners.iter().any(|r| r.image_list().is_none()) {
889
        return;
890
    }
891
    for r in &runners {
892
        held.extend(r.image_list().unwrap_or_default());
893
    }
894
    let missing: Vec<&String> = pinned.iter().filter(|h| !held.contains(*h)).collect();
895
    // Only a missing image makes the check; one that was failing on this
896
    // state turns to passed once its images are held.
897
    let failing_before = db::hooks::checks_for_state(&app.db, row.id, state)
898
        .await
899
        .unwrap_or_default()
900
        .iter()
901
        .any(|c| c.name == "pipeline/images" && c.status != "passed");
902
    if missing.is_empty() && !failing_before {
903
        return;
904
    }
905
    let (status, summary) = if missing.is_empty() {
906
        (
907
            "passed",
908
            format!("{} now held", if pinned.len() == 1 { "its image is".to_string() } else { format!("all {} images are", pinned.len()) }),
909
        )
910
    } else {
911
        let names: Vec<String> = missing.iter().map(|h| h[..12.min(h.len())].to_string()).collect();
912
        ("failed", format!("neither the hub nor a runner holds image {}: send it with flow rootfs push HASH", names.join(", ")))
913
    };
914
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
915
    if let Ok(c) = db::hooks::upsert_check(
916
        &app.db,
917
        row.id,
918
        db::hooks::CheckReport { state, name: "pipeline/images", status, summary: &summary, url: &url, reporter: None, seal: "" },
919
    )
920
    .await
921
    {
922
        let api = crate::api::check_of(&c);
923
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
924
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
925
    }
926
}
927
928
/// Record a job's check; it links to the run's page by the run's number.
929
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
930
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
931
    let name = format!("flow/{job}");
932
    let c = db::hooks::upsert_check(
933
        &app.db,
934
        row.id,
935
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
936
    )
937
    .await;
938
    if let Ok(c) = c {
939
        let api = crate::api::check_of(&c);
940
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
941
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
942
    }
943
}
944
945
/// The check status a job's status shows as.
946
fn check_status(job_status: &str) -> &'static str {
947
    match job_status {
948
        "queued" => "queued",
949
        "running" => "running",
950
        "passed" => "passed",
951
        "skipped" => "skipped",
952
        "cancelled" => "cancelled",
953
        _ => "failed",
954
    }
955
}
956
957
/// The runner a bearer token belongs to: a registered runner, or one
958
/// configured with a hub.toml token (tracked from its first request). A
959
/// disabled runner is refused.
960
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
961
    let Some(t) = oxy_server::bearer_token(headers) else {
962
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
963
    };
964
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
965
    let runner = match db::runners::by_token(&app.db, &hash).await? {
966
        Some(r) => r,
967
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
968
            db::runners::configured(&app.db, &hash, "runner").await?
969
        }
970
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
971
    };
972
    if runner.state == "disabled" {
973
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
974
    }
975
    Ok(runner)
976
}
977
978
/// `POST /api/v1/flow/runners/register`: a one-time registration token
979
/// from Admin → Runners buys a runner credential, binding the runner's
980
/// signing identity (hub spec §13.2).
981
pub async fn register(
982
    State(app): State<Shared>,
983
    Json(req): Json<flow_core::api::Register>,
984
) -> Result<Json<flow_core::api::Registered>, ApiError> {
985
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
986
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
987
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
988
    if name.is_empty() {
989
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
990
    }
991
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
992
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
993
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
994
    let runner =
995
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
996
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
997
        })?;
998
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
999
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
1000
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
1001
}
1002
1003
/// `POST /api/v1/flow/claim`.
1004
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
1005
    let me = runner_auth(&app, &headers).await?;
1006
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
1007
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
1008
    if let Some(holding) = &req.holding {
1009
        end_jobs_not_held(&app, &me, holding).await?;
1010
    }
1011
    // A draining runner finishes what it has and takes nothing new.
1012
    if me.state == "draining" {
1013
        return Ok(StatusCode::NO_CONTENT.into_response());
1014
    }
1015
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
1016
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
1017
    // What it holds and what it is (OxygenFlow spec §11): the images'
1018
    // architectures it reports are remembered for every runner's claims.
1019
    if let Some(archs) = &req.image_archs {
1020
        fdb::record_image_archs(&app.db, archs).await?;
1021
    }
1022
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
1023
    // What it offers, kept for the placement pass; a job it declined is
1024
    // not offered to it again while this stays the same (spec §11.5).
1025
    let profile = profile_of(&req, &arch);
1026
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
1027
    if let Some(f) = &req.facts {
1028
        db::runners::set_facts(&app.db, me.id, f).await?;
1029
    }
1030
    app.swarm.claimed(me.id, req.swarm.as_ref());
1031
    // The build caches it holds (oxygenflow#51), and when each last kept a
1032
    // disk (oxygenflow#54; 0 from an older runner), for placing jobs by.
1033
    if let Some(c) = &req.caches {
1034
        let kept = |slot: &str| req.cache_times.as_ref().and_then(|t| t.get(slot)).map_or(0, |ms| *ms as i64);
1035
        CACHES
1036
            .lock()
1037
            .unwrap_or_else(|p| p.into_inner())
1038
            .get_or_insert_with(Default::default)
1039
            .insert((app.repos.root().display().to_string(), me.id), c.iter().map(|s| (s.clone(), kept(s))).collect());
1040
    }
1041
    let room = fdb::Room {
1042
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
1043
        memory_mib: req.free_memory_mib,
1044
        cpus: req.free_cpus,
1045
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
1046
        arch,
1047
        image_archs: fdb::image_archs(&app.db).await?,
1048
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
1049
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
1050
    };
1051
    // The labels it reports now, or those it registered with, and those
1052
    // an administrator gave it (#98).
1053
    let mut labels: Vec<String> = match &req.info {
1054
        Some(i) => i.labels.clone(),
1055
        None => me.reported_label_list().into_iter().map(String::from).collect(),
1056
    };
1057
    for l in me.admin_label_list() {
1058
        if !labels.iter().any(|x| x == l) {
1059
            labels.push(l.to_string());
1060
        }
1061
    }
1062
    // Ranking (oxygenflow#42): a job another free runner would do better
1063
    // (it holds the image, or is faster) is held for it briefly.
1064
    let runners = db::runners::list(&app.db).await?;
1065
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
1066
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
1067
    let queue = fdb::queue(&app.db).await?;
1068
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
1069
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
1070
    let slots: std::collections::HashMap<i64, String> =
1071
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
1072
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
1073
    let now = db::now_ms();
1074
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
1075
    let hub_key = app.repos.root().display().to_string();
1076
    let better = |j: &fdb::Job| -> bool {
1077
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
1078
        let rootfs = rootfs_of(j);
1079
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
1080
        let holds = holds.get_or_insert_with(Default::default);
1081
        holds.retain(|_, t| now - *t < 600_000);
1082
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
1083
            return false;
1084
        }
1085
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
1086
        let mine = fit(&hub_key, &me_now, &rootfs, slot);
1087
        let other = runners.iter().any(|r| {
1088
            r.id != me.id
1089
                && r.state == "active"
1090
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
1091
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
1092
                && fit(&hub_key, r, &rootfs, slot).beats(&mine)
1093
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
1094
        });
1095
        if other {
1096
            holds.entry((hub_key.clone(), j.id)).or_insert(now);
1097
        }
1098
        other
1099
    };
1100
    let Some(job) =
1101
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
1102
    else {
1103
        return Ok(StatusCode::NO_CONTENT.into_response());
1104
    };
1105
    // Why here (oxygenflow#42).
1106
    let waited =
1107
        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);
1108
    let rootfs = rootfs_of(&job);
1109
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
1110
        "holds the image".to_string()
1111
    } else {
1112
        "fetches the image from the hub".to_string()
1113
    }];
1114
    if let Some(kept) = slots.get(&job.id).and_then(|slot| cache_kept(&hub_key, me.id, slot)) {
1115
        why.insert(
1116
            0,
1117
            if kept > 0 { format!("holds its build cache (kept {} ago)", ago(now - kept)) } else { "holds its build cache".into() },
1118
        );
1119
    }
1120
    match running.get(&me.id).copied().unwrap_or(0) {
1121
        0 => why.push("idle".into()),
1122
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
1123
    }
1124
    if let Some(sp) = me_now.speed {
1125
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
1126
    }
1127
    if let Some(w) = waited {
1128
        why.push(format!("after {} s held for a better runner", w / 1000));
1129
    }
1130
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
1131
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
1132
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1133
    let row =
1134
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1135
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
1136
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
1137
    // The job's limit: its own timeout, else its sandbox's, else the site
1138
    // default, never past the site maximum; recorded for the reaper.
1139
    let limits = limits(&app).await;
1140
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
1141
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
1142
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
1143
    // ...and never past the runner's own limit, when it has one (#98).
1144
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
1145
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
1146
    }
1147
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
1148
    let claim = Claim {
1149
        job_id: job.id,
1150
        run_id: run.id,
1151
        repo: format!("{}/{}", row.owner, row.name),
1152
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
1153
        token: secret,
1154
        heads: run.head_list().into_iter().map(String::from).collect(),
1155
        pipeline_hash: run.pipeline_hash.clone(),
1156
        job: spec,
1157
        sandbox,
1158
        timeout_s,
1159
        subrepos: vec![],
1160
        state: run.state.clone(),
1161
        bookmark: run.bookmark.clone(),
1162
        run_number: run.number,
1163
        // The site's, the owner's and the repository's variables (#102).
1164
        env: crate::variables::env_for_repo(&app.db, &row).await?,
1165
        secrets: vec![],
1166
        inputs: vec![],
1167
        // For a runner that must leave: finish it, or hand it back
1168
        // (oxygenflow#76).
1169
        expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64),
1170
        // What an attempt handed back saved, to resume from (oxygenflow#77).
1171
        checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None },
1172
    };
1173
    let mut claim = claim;
1174
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
1175
    // The secrets the job declares, as files (#107); one not set is said
1176
    // at the top of the log and left out, so the job fails on the file.
1177
    let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect();
1178
    let found = crate::secrets::for_job(&app, &row, &names).await?;
1179
    // The identities it asks for (#103): a token each, as a file.
1180
    // An environment goes in its tokens only once the job was approved
1181
    // (oxygenflow#59): the approval is the environment's protection.
1182
    let environment = claim.job.environment.clone().filter(|_| job.approval == "approved");
1183
    if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) {
1184
        left_out.push(format!(
1185
            "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)"
1186
        ));
1187
    }
1188
    if !claim.job.identities.is_empty() {
1189
        let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?;
1190
        for i in &claim.job.identities {
1191
            let token = issuer.mint(&crate::oidc::JobClaims {
1192
                issuer: app.config.base_url(),
1193
                audience: &i.audience,
1194
                owner: &row.owner,
1195
                name: &row.name,
1196
                visibility: &row.visibility,
1197
                bookmark: &run.bookmark,
1198
                run_number: run.number,
1199
                job: &job.name,
1200
                state: &run.state,
1201
                runner: &runner,
1202
                actor: &run.actor,
1203
                environment: environment.as_deref(),
1204
                approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()),
1205
                approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()),
1206
                now_ms: db::now_ms(),
1207
                ttl_s: timeout_s,
1208
            });
1209
            claim.secrets.push(flow_core::api::SecretInput {
1210
                name: format!("identity:{}", i.audience),
1211
                path: i.mount_path.clone(),
1212
                value: token,
1213
            });
1214
        }
1215
    }
1216
    for s in &claim.job.secrets {
1217
        match found.iter().find(|(n, _)| *n == s.name) {
1218
            Some((name, value)) => {
1219
                claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() })
1220
            }
1221
            None => left_out.push(format!(
1222
                "secret {:?} is not set for this repository, its owner or the site; {} will be missing",
1223
                s.name, s.mount_path
1224
            )),
1225
        }
1226
    }
1227
    if !ids.is_empty() {
1228
        fdb::set_also_read(&app.db, job.id, &ids).await?;
1229
    }
1230
    // Say at the top of the job's log what it will not have (oxygenhub#56),
1231
    // rather than let it fail later on missing files.
1232
    if !left_out.is_empty() {
1233
        let records: Vec<flow_core::api::Record> =
1234
            left_out.into_iter().map(|text| flow_core::api::Record { t: 0, stream: "flow".into(), text }).collect();
1235
        fdb::append_log(&app.db, job.id, &records, MAX_RECORDS as i64).await?;
1236
    }
1237
    claim.subrepos = inputs;
1238
    // Artifacts of jobs it depends on (oxygenflow#58), with the blob each
1239
    // producer's receipt names; the runner checks what it fetches against it.
1240
    match artifact_inputs(&app, &job, &claim.job).await {
1241
        Ok(found) => claim.inputs = found,
1242
        Err(why) => {
1243
            let error = format!("inputs: {why}");
1244
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1245
                .await?;
1246
            return Err(ApiError(StatusCode::CONFLICT, error));
1247
        }
1248
    }
1249
    Ok(Json(claim).into_response())
1250
}
1251
1252
/// The artifacts a job takes (oxygenflow#58), each from a job of the same
1253
/// run that passed with a receipt; a passing job's receipt names exactly the
1254
/// artifacts it uploaded (checked at finish), so their blobs are attested.
1255
async fn artifact_inputs(app: &Shared, job: &fdb::Job, spec: &flow_core::Job) -> Result<Vec<flow_core::api::ArtifactInput>, String> {
1256
    if spec.inputs.is_empty() {
1257
        return Ok(vec![]);
1258
    }
1259
    let jobs = fdb::jobs(&app.db, job.run_id).await.map_err(|e| e.to_string())?;
1260
    let mut out = Vec::new();
1261
    for i in &spec.inputs {
1262
        let Some(producer) = jobs.iter().find(|j| j.name == i.from) else {
1263
            return Err(format!("{:?} is not a job of this run", i.from));
1264
        };
1265
        if producer.status != "passed" || producer.receipt_hash.is_empty() {
1266
            return Err(format!("{} has not passed with a receipt, so {} cannot be taken from it", i.from, i.artifact));
1267
        }
1268
        let artifacts = fdb::artifacts(&app.db, producer.id).await.map_err(|e| e.to_string())?;
1269
        let Some(a) = artifacts.iter().find(|a| a.name == i.artifact) else {
1270
            return Err(format!("{} kept no artifact {}", i.from, i.artifact));
1271
        };
1272
        out.push(flow_core::api::ArtifactInput {
1273
            artifact: i.artifact.clone(),
1274
            from: i.from.clone(),
1275
            path: i.path.clone(),
1276
            blob: a.blob.clone(),
1277
        });
1278
    }
1279
    Ok(out)
1280
}
1281
1282
/// `GET /api/v1/flow/jobs/{id}/inputs/{index}`: the bytes of input `index`
1283
/// of a running job (oxygenflow#58), for the runner holding it.
1284
pub async fn input(State(app): State<Shared>, headers: HeaderMap, Path((id, index)): Path<(i64, usize)>) -> Result<Response, ApiError> {
1285
    let runner = runner_auth(&app, &headers).await?;
1286
    let job = running_job(&app, id).await?;
1287
    // A registered runner reads only the inputs of a job it holds (a
1288
    // configured token's runner names itself when it claims).
1289
    if runner.source == "registered" && job.runner != runner.name {
1290
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1291
    }
1292
    let spec: flow_core::Job =
1293
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1294
    let found = artifact_inputs(&app, &job, &spec).await.map_err(|why| ApiError(StatusCode::CONFLICT, why))?;
1295
    let Some(i) = found.get(index) else {
1296
        return Err(ApiError(StatusCode::NOT_FOUND, format!("job {} has no input {index}", job.name)));
1297
    };
1298
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1299
    let row =
1300
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1301
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
1302
    let blob = oxy_core::hash::ObjectRef::from_hex(&i.blob)
1303
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept artifact has a bad blob".into()))?;
1304
    let bytes = app
1305
        .repos
1306
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1307
        .await
1308
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the artifact: {e}")))?;
1309
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1310
}
1311
1312
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
1313
/// labels and capacity, each list sorted.
1314
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
1315
    let sorted = |v: &[String]| {
1316
        let mut v = v.to_vec();
1317
        v.sort();
1318
        v
1319
    };
1320
    let info = req.info.as_ref();
1321
    let v = serde_json::json!({
1322
        "arch": arch,
1323
        "tiers": sorted(&req.runtimes),
1324
        "images": req.images.as_deref().map(sorted),
1325
        "labels": info.map(|i| sorted(&i.labels)),
1326
        "memory_mib": info.and_then(|i| i.memory_mib),
1327
        "cpus": info.and_then(|i| i.cpus),
1328
    });
1329
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
1330
}
1331
1332
/// A job handed back by this many different runners is unplaceable.
1333
pub const UNPLACEABLE_AFTER: usize = 3;
1334
1335
/// How long a runner holds a job without the hub hearing from it (claims,
1336
/// heartbeats every 30 s, logs) before the job is handed back
1337
/// (oxygenflow#76).
1338
pub const LEASE_MS: i64 = 3 * 60_000;
1339
1340
/// A job handed back more times than this ends as an error rather than
1341
/// bounce from runner to runner.
1342
pub const MAX_HANDBACKS: i64 = 5;
1343
1344
/// Hand job `id` back to the queue, ahead of new work (oxygenflow#76): its
1345
/// runner drained before leaving (`runner` is it), or its lease lapsed
1346
/// (`None`). The log says why, and the next attempt's follows on. False
1347
/// when the job was not running there.
1348
pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> {
1349
    let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) };
1350
    let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) };
1351
    let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0);
1352
    let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") };
1353
    fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok();
1354
    tracing::info!("flow: job {id} handed back ({n}): {why}");
1355
    if n > MAX_HANDBACKS {
1356
        let error = format!("handed back {n} times; the last: {why}");
1357
        let changed =
1358
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1359
                .await?;
1360
        report_changed(app, before.run_id, changed).await?;
1361
    } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? {
1362
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1363
            let summary = format!("{why}; queued again ahead of new work");
1364
            report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await;
1365
        }
1366
    }
1367
    Ok(true)
1368
}
1369
1370
/// Hand back the jobs of runners not heard from for [`LEASE_MS`]: a phone
1371
/// that lost every network, a host that died (oxygenflow#76).
1372
pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> {
1373
    let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?;
1374
    let mut n = 0;
1375
    for (job, runner) in lapsed {
1376
        let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000);
1377
        if hand_back_job(app, job.id, None, &why).await? {
1378
            n += 1;
1379
        }
1380
    }
1381
    Ok(n)
1382
}
1383
1384
/// `POST /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): a runner
1385
/// handing a job back keeps its checkpoint directories, packed; kept as a
1386
/// blob of the job's repository, for the next attempt's claim to name.
1387
pub async fn save_checkpoint(
1388
    State(app): State<Shared>,
1389
    headers: HeaderMap,
1390
    Path(id): Path<i64>,
1391
    body: axum::body::Bytes,
1392
) -> Result<Response, ApiError> {
1393
    let runner = runner_auth(&app, &headers).await?;
1394
    let job = running_job(&app, id).await?;
1395
    if runner.source == "registered" && job.runner != runner.name {
1396
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1397
    }
1398
    let spec: flow_core::Job =
1399
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1400
    if spec.checkpoints.is_empty() {
1401
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name)));
1402
    }
1403
    let repo = job_repo(&app, &job).await?;
1404
    let content = blake3::hash(&body).to_hex().to_string();
1405
    let size = body.len() as i64;
1406
    let blob = app
1407
        .repos
1408
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1409
        .await
1410
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?;
1411
    let a = fdb::Artifact {
1412
        job_id: job.id,
1413
        name: fdb::CHECKPOINT.into(),
1414
        path: spec.checkpoints.join(" "),
1415
        blob: blob.to_hex(),
1416
        size,
1417
        content,
1418
        created_ms: db::now_ms(),
1419
    };
1420
    fdb::add_artifact(&app.db, &a).await?;
1421
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1422
}
1423
1424
/// `GET /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): the checkpoint
1425
/// a handed-back attempt saved, for the attempt now running it.
1426
pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> {
1427
    let runner = runner_auth(&app, &headers).await?;
1428
    let job = running_job(&app, id).await?;
1429
    if runner.source == "registered" && job.runner != runner.name {
1430
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1431
    }
1432
    let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?;
1433
    let repo = job_repo(&app, &job).await?;
1434
    let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob)
1435
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?;
1436
    let bytes = app
1437
        .repos
1438
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1439
        .await
1440
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?;
1441
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1442
}
1443
1444
/// The repository job `job` runs for.
1445
async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> {
1446
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1447
    let row =
1448
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1449
    RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))
1450
}
1451
1452
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1453
/// runner hands back a job it holds, as it leaves (off the allowed network,
1454
/// a restart, a spot reclaim). Not a decline: the runner is not held from
1455
/// the job and it does not count towards unplaceable.
1456
pub async fn release(
1457
    State(app): State<Shared>,
1458
    headers: HeaderMap,
1459
    Path(id): Path<i64>,
1460
    Json(d): Json<flow_core::api::Decline>,
1461
) -> Result<StatusCode, ApiError> {
1462
    let me = runner_auth(&app, &headers).await?;
1463
    db::runners::seen(&app.db, me.id, None, None).await?;
1464
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1465
    let why = match reason.trim() {
1466
        "" => format!("handed back by {}", me.name),
1467
        r => format!("handed back by {}: {r}", me.name),
1468
    };
1469
    if !hand_back_job(&app, id, Some(me.id), &why).await? {
1470
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1471
    }
1472
    Ok(StatusCode::NO_CONTENT)
1473
}
1474
1475
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1476
/// runner will not run a job it was handed. The job is queued again and
1477
/// not offered to that runner while its profile stays the same (and for
1478
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
1479
/// different runners have declined it, it ends as unplaceable with their
1480
/// reasons. A decline is never a failed check.
1481
/// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose
1482
/// job slots are all full claims nothing, so it says it is there.
1483
pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
1484
    let me = runner_auth(&app, &headers).await?;
1485
    db::runners::seen(&app.db, me.id, None, None).await?;
1486
    Ok(StatusCode::NO_CONTENT)
1487
}
1488
1489
pub async fn decline(
1490
    State(app): State<Shared>,
1491
    headers: HeaderMap,
1492
    Path(id): Path<i64>,
1493
    Json(d): Json<flow_core::api::Decline>,
1494
) -> Result<StatusCode, ApiError> {
1495
    let me = runner_auth(&app, &headers).await?;
1496
    db::runners::seen(&app.db, me.id, None, None).await?;
1497
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1498
    let reason = match reason.trim() {
1499
        "" => "no reason given".to_string(),
1500
        r => r.to_string(),
1501
    };
1502
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
1503
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1504
    };
1505
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
1506
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1507
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
1508
    if runners.len() >= UNPLACEABLE_AFTER {
1509
        let error = format!(
1510
            "unplaceable: declined by {} runners: {}",
1511
            runners.len(),
1512
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
1513
        );
1514
        let changed =
1515
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1516
                .await?;
1517
        report_changed(&app, job.run_id, changed).await?;
1518
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
1519
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1520
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
1521
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
1522
            fdb::set_waiting(&app.db, id, &summary).await?;
1523
        }
1524
    }
1525
    Ok(StatusCode::NO_CONTENT)
1526
}
1527
1528
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1529
/// now (spec §11.4), from what each runner offered at its last claim:
1530
/// empty when one could (it waits its turn or a free slot), else what
1531
/// each one lacks. `declines` are the job's.
1532
/// What placement knows of the fleet: images' architectures, the images
1533
/// the hub holds, and the time.
1534
pub struct Fleet<'a> {
1535
    pub image_archs: &'a std::collections::HashMap<String, String>,
1536
    pub hub_images: &'a std::collections::HashSet<String>,
1537
    pub now: i64,
1538
}
1539
1540
/// Why runner `r` cannot take `job` of a repository of `owner` (id
1541
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
1542
/// when it can. `declines` are the job's.
1543
pub fn runner_lacks(
1544
    job: &fdb::Job,
1545
    owner: &str,
1546
    repo_id: i64,
1547
    r: &db::runners::Runner,
1548
    fleet: &Fleet,
1549
    declines: &[fdb::DeclineRow],
1550
) -> Option<String> {
1551
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
1552
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
1553
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
1554
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
1555
    if r.state == "draining" {
1556
        return Some("is draining".into());
1557
    }
1558
    let scope = r.scope();
1559
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
1560
        return Some("serves other repositories".into());
1561
    }
1562
    let tiers = r.tier_list();
1563
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
1564
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
1565
    }
1566
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
1567
        return Some(format!("is {}; the job needs {w}", r.arch));
1568
    }
1569
    // A job with no image (the WASM tier) needs none on the runner, as its claim says.
1570
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1571
    if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1572
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1573
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1574
    }
1575
    let labels = r.label_list();
1576
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
1577
        return Some(format!("lacks label {l}"));
1578
    }
1579
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
1580
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
1581
    }
1582
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
1583
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
1584
    }
1585
    // The disk it learned it needs (oxygenflow#41).
1586
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
1587
        if free < n {
1588
            return Some(format!(
1589
                "has {} free; the job needs ~{} (its recent runs took up to {})",
1590
                mib(free),
1591
                mib(n),
1592
                mib((n - 64) * 4 / 5)
1593
            ));
1594
        }
1595
    }
1596
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
1597
        return Some(format!("declined it: {}", d.reason));
1598
    }
1599
    None
1600
}
1601
1602
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
1603
/// now (spec §11.4), from what each runner offered at its last claim:
1604
/// empty when one could (it waits its turn or a free slot), else what
1605
/// each one lacks. `declines` are the job's.
1606
#[allow(clippy::too_many_arguments)]
1607
pub fn why_waiting(
1608
    job: &fdb::Job,
1609
    owner: &str,
1610
    repo_id: i64,
1611
    runners: &[db::runners::Runner],
1612
    image_archs: &std::collections::HashMap<String, String>,
1613
    declines: &[fdb::DeclineRow],
1614
    now: i64,
1615
    hub_images: &std::collections::HashSet<String>,
1616
) -> String {
1617
    let fleet = Fleet { image_archs, hub_images, now };
1618
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1619
    if online.is_empty() {
1620
        return "no runner is online".into();
1621
    }
1622
    let mut why = Vec::new();
1623
    for r in &online {
1624
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
1625
            None => return String::new(),
1626
            Some(w) => why.push(format!("{} {w}", r.name)),
1627
        }
1628
    }
1629
    let more = why.len().saturating_sub(4);
1630
    why.truncate(4);
1631
    let mut text = format!("no runner can take it: {}", why.join("; "));
1632
    if more > 0 {
1633
        text.push_str(&format!("; and {more} more"));
1634
    }
1635
    text
1636
}
1637
1638
/// Why a job that some runner could take still waits (oxygenhub#259): the
1639
/// runners that could take it are all busy, or have a free slot without
1640
/// the room for it now, and how many ready jobs come first (a higher
1641
/// priority, or the same one and older).
1642
#[allow(clippy::too_many_arguments)]
1643
pub fn why_still_waiting(
1644
    job: &fdb::Job,
1645
    owner: &str,
1646
    repo_id: i64,
1647
    runners: &[db::runners::Runner],
1648
    fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64),
1649
    declines: &[fdb::DeclineRow],
1650
    running: &std::collections::HashMap<i64, i64>,
1651
    ahead: usize,
1652
    behind_urgent: &[String],
1653
) -> String {
1654
    let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 };
1655
    let mut busy = Vec::new();
1656
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
1657
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
1658
            continue;
1659
        }
1660
        let n = running.get(&r.id).copied().unwrap_or(0);
1661
        let slots = r.max_jobs.max(1);
1662
        if n >= slots {
1663
            busy.push(format!("{} busy ({n} of {slots})", r.name));
1664
        } else {
1665
            busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory or disk for it now", r.name));
1666
        }
1667
    }
1668
    let mut parts = Vec::new();
1669
    if !busy.is_empty() {
1670
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
1671
    }
1672
    if ahead > 0 {
1673
        let after = match behind_urgent {
1674
            [] => String::new(),
1675
            [one] => format!(" (after expedited run {one})"),
1676
            many => format!(" (after expedited runs {})", many.join(", ")),
1677
        };
1678
        parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" }));
1679
    }
1680
    parts.join("; ")
1681
}
1682
1683
/// How long a job is held for a better runner before any runner that fits
1684
/// takes it (oxygenflow#42).
1685
const BETTER_HOLD_MS: i64 = 15_000;
1686
/// A runner counts as there to take a job when it asked this recently.
1687
const POLLING_MS: i64 = 10_000;
1688
1689
/// When each job was first passed over for a better runner.
1690
/// Keyed by hub (tests run several in one process) and job.
1691
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
1692
1693
/// The build cache slots each runner said at its last claim it holds
1694
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
1695
/// runner; a restart forgets them until each runner's next claim.
1696
/// Each slot maps to when it last kept a disk (Unix ms, 0 unknown).
1697
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashMap<String, i64>>;
1698
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
1699
1700
/// When runner `runner` last kept a disk in build cache slot `slot` (0 when
1701
/// it did not say); `None` when it holds none there.
1702
fn cache_kept(hub: &str, runner: i64, slot: &str) -> Option<i64> {
1703
    CACHES
1704
        .lock()
1705
        .unwrap_or_else(|p| p.into_inner())
1706
        .as_ref()
1707
        .and_then(|c| c.get(&(hub.to_string(), runner)))
1708
        .and_then(|held| held.get(slot).copied())
1709
}
1710
1711
/// A copy of a build cache kept at least this much later is the fresher
1712
/// (oxygenflow#54): closer than that, the two are as good as each other.
1713
const FRESHER_MS: i64 = 60_000;
1714
1715
/// How well a runner fits a job (oxygenflow#42, #51, #54).
1716
struct Fit {
1717
    /// When it last kept the job's build cache, if it holds it.
1718
    cache: Option<i64>,
1719
    image: bool,
1720
    speed: i64,
1721
}
1722
1723
impl Fit {
1724
    /// Whether this fit is better than `other`: holding the job's build
1725
    /// cache counts most (a warm build beats any image fetch), and of two
1726
    /// that hold it the fresher copy; then holding the image, then speed.
1727
    fn beats(&self, other: &Fit) -> bool {
1728
        match (self.cache, other.cache) {
1729
            (Some(_), None) => return true,
1730
            (None, Some(_)) => return false,
1731
            (Some(a), Some(b)) if a > 0 && b > 0 && (a - b).abs() >= FRESHER_MS => return a > b,
1732
            _ => {}
1733
        }
1734
        (self.image, self.speed) > (other.image, other.speed)
1735
    }
1736
}
1737
1738
/// How runner `r` fits a job on image `rootfs` whose build cache slot is
1739
/// `slot`.
1740
fn fit(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> Fit {
1741
    Fit {
1742
        cache: cache_kept(hub, r.id, slot),
1743
        image: r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)),
1744
        speed: r.speed_class.unwrap_or(0),
1745
    }
1746
}
1747
1748
/// A span as people say it: 40 s, 12 min, 3 h, 2 days.
1749
fn ago(ms: i64) -> String {
1750
    let s = ms.max(0) / 1000;
1751
    match s {
1752
        0..=59 => format!("{s} s"),
1753
        60..=3599 => format!("{} min", s / 60),
1754
        3600..=172_799 => format!("{} h", s / 3600),
1755
        _ => format!("{} days", s / 86_400),
1756
    }
1757
}
1758
1759
fn rootfs_of(job: &fdb::Job) -> String {
1760
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
1761
}
1762
1763
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
1764
fn mib(m: i64) -> String {
1765
    if m >= 1024 {
1766
        format!("{:.1} GiB", m as f64 / 1024.0)
1767
    } else {
1768
        format!("{m} MiB")
1769
    }
1770
}
1771
1772
/// One placement pass: say why each ready queued job waits.
1773
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
1774
    let ready = fdb::ready_queued(&app.db).await?;
1775
    if ready.is_empty() {
1776
        return Ok(());
1777
    }
1778
    let runners = db::runners::list(&app.db).await?;
1779
    let archs = fdb::image_archs(&app.db).await?;
1780
    let held = crate::images::held(&app.db).await?;
1781
    let now = db::now_ms();
1782
    let ranks = fdb::queued_ranks(&app.db).await?;
1783
    let running = fdb::running_by_runner(&app.db).await?;
1784
    // Claim order, roughly: a better rank first, then the older job.
1785
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
1786
    let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect();
1787
    // Expedited runs (rank 0) by name, for the jobs they moved down (#291).
1788
    let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new();
1789
    for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) {
1790
        if urgent.contains_key(&j.run_id) {
1791
            continue;
1792
        }
1793
        if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? {
1794
            if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1795
                urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number));
1796
            }
1797
        }
1798
    }
1799
    // Runs whose waiting notes changed: their pages hear of it now.
1800
    let mut changed = std::collections::BTreeSet::new();
1801
    for (job, owner, repo_id) in ready {
1802
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1803
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1804
        if why.is_empty() {
1805
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
1806
            let ahead = order.iter().filter(|o| **o < mine).count();
1807
            let mut behind: Vec<String> = Vec::new();
1808
            if mine.0 > 0 {
1809
                for o in order.iter().filter(|o| o.0 == 0 && **o < mine) {
1810
                    let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue };
1811
                    if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) {
1812
                        behind.push(label.clone());
1813
                    }
1814
                }
1815
            }
1816
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind);
1817
        }
1818
        if why == job.waiting {
1819
            continue;
1820
        }
1821
        fdb::set_waiting(&app.db, job.id, &why).await?;
1822
        changed.insert(job.run_id);
1823
        // The job's check says it too, so a proposal shows why it is stuck
1824
        // (oxygenhub#28); only when the reason changes in kind, not in its
1825
        // counts (#259: a queue position moves every pass, and each check
1826
        // report reaches webhooks).
1827
        let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>();
1828
        if kind(&why) == kind(&job.waiting) {
1829
            continue;
1830
        }
1831
        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?) {
1832
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1833
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1834
        }
1835
    }
1836
    for run_id in changed {
1837
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
1838
    }
1839
    Ok(())
1840
}
1841
1842
/// Run a placement pass every 15 seconds.
1843
pub async fn placement(app: Shared) {
1844
    loop {
1845
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
1846
        if let Err(e) = explain_waits(&app).await {
1847
            tracing::warn!("flow: placement pass: {e}");
1848
        }
1849
    }
1850
}
1851
1852
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
1853
pub(crate) async fn pins_in(
1854
    app: &Shared,
1855
    name: crate::repos::RepoName,
1856
    heads: Vec<oxy_core::hash::ChangeHash>,
1857
) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> {
1858
    app.repos
1859
        .read(&name, move |repo, cache| {
1860
            let loaded = cache.load(repo, &heads)?;
1861
            let mut out = Vec::new();
1862
            for (path, f) in &loaded.snapshot.files {
1863
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
1864
                    continue;
1865
                }
1866
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
1867
                    if let Some(b) = blobs.first() {
1868
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
1869
                            .read_blob(b)
1870
                            .map_err(crate::repos::RepoError::Oxy)
1871
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
1872
                        {
1873
                            out.push((path.clone(), id, heads));
1874
                        }
1875
                    }
1876
                }
1877
            }
1878
            Ok(out)
1879
        })
1880
        .await
1881
        .map_err(|e| e.to_string())
1882
}
1883
1884
/// How deep subrepos of subrepos are followed.
1885
const MAX_SUBREPO_DEPTH: usize = 3;
1886
1887
/// The Oxygen subrepos the run's state pins, and those their states pin in
1888
/// turn (parents first, paths joined), that live on this hub and that the
1889
/// run's pusher may read: what the job's workspace needs beside its own
1890
/// tree. A pin the hub cannot serve is left out, with what it pins, and
1891
/// the third list says why, for the job's log.
1892
async fn subrepo_inputs(
1893
    app: &Shared,
1894
    row: &db::repos::RepoRow,
1895
    run: &fdb::Run,
1896
) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>, Vec<String>) {
1897
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![], vec![]) };
1898
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1899
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1900
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
1901
    let mut inputs = Vec::new();
1902
    let mut ids = Vec::new();
1903
    let mut left_out = Vec::new();
1904
    let actor = if run.actor.is_empty() { "the run's starter".to_string() } else { run.actor.clone() };
1905
    // (path prefix, repository, heads, depth), parents before children.
1906
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
1907
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
1908
        if depth == MAX_SUBREPO_DEPTH {
1909
            continue;
1910
        }
1911
        for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() {
1912
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
1913
            let mut hosted = false;
1914
            for other in &rows {
1915
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
1916
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
1917
                if !matches {
1918
                    continue;
1919
                }
1920
                hosted = true;
1921
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
1922
                // A Diffusion bump's run reads the upstream it bumps: the one
1923
                // the consumer pins (someone who may write it committed the
1924
                // pin), and no other (#150).
1925
                let diffused = run.actor == crate::diffusion::ACCOUNT
1926
                    && run.bookmark.starts_with("diffusion/")
1927
                    && sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1 AND upstream_repo_id = $2")
1928
                        .bind(run.repo_id)
1929
                        .bind(other.id)
1930
                        .fetch_one(&app.db)
1931
                        .await
1932
                        .unwrap_or(0)
1933
                        > 0;
1934
                if diffused || crate::auth::roles::allows(role, crate::auth::Access::Read) {
1935
                    inputs.push(flow_core::api::SubrepoInput {
1936
                        path: path.clone(),
1937
                        repo: format!("{}/{}", other.owner, other.name),
1938
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
1939
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
1940
                    });
1941
                    if !ids.contains(&other.id) {
1942
                        ids.push(other.id);
1943
                    }
1944
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
1945
                } else {
1946
                    left_out.push(format!(
1947
                        "subrepo {path} ({}/{}) is left out: {actor} cannot read it, so the job has no files there",
1948
                        other.owner, other.name
1949
                    ));
1950
                }
1951
                break;
1952
            }
1953
            if !hosted {
1954
                left_out.push(format!("subrepo {path} is left out: the repository it pins is not on this hub"));
1955
            }
1956
        }
1957
    }
1958
    (inputs, ids, left_out)
1959
}
1960
1961
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
1962
    match fdb::job(&app.db, id).await? {
1963
        Some(j) if j.status == "running" => Ok(j),
1964
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
1965
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
1966
    }
1967
}
1968
1969
/// `POST /api/v1/flow/jobs/{id}/log`.
1970
pub async fn log(
1971
    State(app): State<Shared>,
1972
    headers: HeaderMap,
1973
    Path(id): Path<i64>,
1974
    Json(mut batch): Json<LogBatch>,
1975
) -> Result<StatusCode, ApiError> {
1976
    runner_auth(&app, &headers).await?;
1977
    let job = running_job(&app, id).await?;
1978
    // An empty batch asks only whether the job still runs: a cancelled
1979
    // one answers 409 and its runner stops it (oxygenflow#32).
1980
    if batch.records.is_empty() {
1981
        return Ok(StatusCode::NO_CONTENT);
1982
    }
1983
    // A secret's value never reaches the stored log (#107).
1984
    let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) {
1985
        Ok(spec) if !spec.secrets.is_empty() => {
1986
            let run = fdb::run_by_id(&app.db, job.run_id).await?;
1987
            let row = match run {
1988
                Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?,
1989
                None => None,
1990
            };
1991
            match row {
1992
                Some(row) => {
1993
                    let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect();
1994
                    crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect()
1995
                }
1996
                None => vec![],
1997
            }
1998
        }
1999
        _ => vec![],
2000
    };
2001
    let kid = app.oidc.get().map(|i| i.kid.clone());
2002
    for r in &mut batch.records {
2003
        if !values.is_empty() {
2004
            r.text = crate::secrets::mask(&r.text, &values);
2005
        }
2006
        if let Some(kid) = &kid {
2007
            r.text = crate::oidc::mask(&r.text, kid);
2008
        }
2009
        if r.text.len() > MAX_RECORD_TEXT {
2010
            let mut cut = MAX_RECORD_TEXT;
2011
            while !r.text.is_char_boundary(cut) {
2012
                cut -= 1;
2013
            }
2014
            r.text.truncate(cut);
2015
        }
2016
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
2017
            r.stream = "console".into();
2018
        }
2019
    }
2020
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
2021
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
2022
        tracing::info!("flow: job {} reached the log limit", job.id);
2023
    }
2024
    Ok(StatusCode::NO_CONTENT)
2025
}
2026
2027
/// Largest artifact the hub keeps; the runner refuses larger ones first.
2028
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
2029
2030
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
2031
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
2032
/// repository. Answers `{"blob"}`.
2033
pub async fn artifact(
2034
    State(app): State<Shared>,
2035
    headers: HeaderMap,
2036
    Path((id, name)): Path<(i64, String)>,
2037
    body: axum::body::Bytes,
2038
) -> Result<Response, ApiError> {
2039
    runner_auth(&app, &headers).await?;
2040
    let job = running_job(&app, id).await?;
2041
    let spec: flow_core::Job =
2042
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2043
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
2044
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
2045
    };
2046
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2047
    let row =
2048
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2049
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2050
    // A report's artifact is its manifest (#127): checked, and its files
2051
    // found in the store, before it is kept.
2052
    let report = match out.kind {
2053
        flow_core::OutputKind::Report => Some(report_manifest(&app, &repo, &name, &body).await?),
2054
        flow_core::OutputKind::Artifact => None,
2055
    };
2056
    let content = blake3::hash(&body).to_hex().to_string();
2057
    let size = body.len() as i64;
2058
    let artifact = report.is_none();
2059
    let report_bytes = (artifact && name == crate::coverage::ARTIFACT).then(|| body.clone());
2060
    let junit_bytes = (artifact && name == crate::junit::ARTIFACT).then(|| body.clone());
2061
    let blob = app
2062
        .repos
2063
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2064
        .await
2065
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
2066
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
2067
    fdb::add_artifact(&app.db, &a).await?;
2068
    if let Some((manifest, declaration)) = report {
2069
        let origin = crate::reports::Origin {
2070
            repo_id: row.id,
2071
            run_id: run.id,
2072
            run_number: run.number,
2073
            job_id: job.id,
2074
            job: &job.name,
2075
            bookmark: &run.bookmark,
2076
            state: &run.state,
2077
        };
2078
        let said = match declaration {
2079
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
2080
                Ok(id) => {
2081
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
2082
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
2083
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
2084
                }
2085
                Err(e) => format!("report {}: not indexed: {e}", a.name),
2086
            },
2087
            Err(why) => {
2088
                let d = crate::reports::Declaration::unreadable(&a.name, &why);
2089
                crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await.ok();
2090
                format!("report {}: report.toml: {why}", a.name)
2091
            }
2092
        };
2093
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2094
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2095
    }
2096
    // A coverage report (#113) is read now; what it says, or why it could
2097
    // not be read, goes in the job's log. It never fails the upload.
2098
    if let Some(bytes) = report_bytes {
2099
        let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await {
2100
            Ok(text) | Err(text) => text,
2101
        };
2102
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2103
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2104
    }
2105
    // A test report (#114) likewise.
2106
    if let Some(bytes) = junit_bytes {
2107
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
2108
            Ok(text) | Err(text) => text,
2109
        };
2110
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
2111
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
2112
    }
2113
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
2114
}
2115
2116
/// A check per metric with a threshold (#127, docs/reports.md §5), named
2117
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
2118
/// `fail_above` or `fail_below`, which a proposal may require.
2119
async fn report_threshold_checks(
2120
    app: &Shared,
2121
    row: &db::repos::RepoRow,
2122
    run: &fdb::Run,
2123
    name: &str,
2124
    id: i64,
2125
    metrics: &[crate::reports::Metric],
2126
) {
2127
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
2128
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
2129
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
2130
        let above = m.fail_above.filter(|a| m.value > *a);
2131
        let below = m.fail_below.filter(|b| m.value < *b);
2132
        let (status, summary) = match (above, below) {
2133
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
2134
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
2135
            _ => {
2136
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
2137
                    .into_iter()
2138
                    .flatten()
2139
                    .collect();
2140
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
2141
            }
2142
        };
2143
        let check = format!("report/{name}/{}", m.key);
2144
        if let Ok(c) = db::hooks::upsert_check(
2145
            &app.db,
2146
            row.id,
2147
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
2148
        )
2149
        .await
2150
        {
2151
            let api = crate::api::check_of(&c);
2152
            app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2153
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2154
        }
2155
    }
2156
}
2157
2158
/// Check report manifest `body` (#127) and that the repository holds every
2159
/// file it names; answers it with the report's declaration, or why that
2160
/// could not be read.
2161
async fn report_manifest(
2162
    app: &Shared,
2163
    repo: &RepoName,
2164
    name: &str,
2165
    body: &[u8],
2166
) -> Result<(crate::reports::Manifest, Result<crate::reports::Declaration, String>), ApiError> {
2167
    let manifest = crate::reports::parse_manifest(body).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
2168
    let refs: Vec<(String, oxy_core::hash::ObjectRef)> =
2169
        manifest.files.iter().filter_map(|f| Some((f.path.clone(), oxy_core::hash::ObjectRef::from_hex(&f.blob)?))).collect();
2170
    let declaration = manifest.blob("report.toml").and_then(oxy_core::hash::ObjectRef::from_hex);
2171
    let found = app
2172
        .repos
2173
        .read(repo, move |r, _| {
2174
            if let Some((path, _)) = refs.iter().find(|(_, b)| !r.store().has(b)) {
2175
                return Ok(Err(path.clone()));
2176
            }
2177
            Ok(Ok(declaration.map(|b| r.read_blob(&b)).transpose()?))
2178
        })
2179
        .await
2180
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the report: {e}")))?;
2181
    let toml = found.map_err(|path| ApiError(StatusCode::BAD_REQUEST, format!("report file {path} was not uploaded; upload it first")))?;
2182
    let declaration = crate::reports::parse_declaration(name, &toml.unwrap_or_default());
2183
    Ok((manifest, declaration))
2184
}
2185
2186
/// `POST /api/v1/flow/jobs/{id}/reports/{name}/files`: one file of a report
2187
/// the job declares (#127), its path in `X-Flow-Report-Path`, kept as a
2188
/// blob of the run's repository. Answers `{"blob"}`; the manifest naming
2189
/// the files follows as the report's artifact.
2190
pub async fn report_file(
2191
    State(app): State<Shared>,
2192
    headers: HeaderMap,
2193
    Path((id, name)): Path<(i64, String)>,
2194
    body: axum::body::Bytes,
2195
) -> Result<Response, ApiError> {
2196
    runner_auth(&app, &headers).await?;
2197
    let job = running_job(&app, id).await?;
2198
    let spec: flow_core::Job =
2199
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
2200
    if !spec.outputs.iter().any(|o| o.name == name && o.kind == flow_core::OutputKind::Report) {
2201
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no report {name:?}", job.name)));
2202
    }
2203
    let path = headers.get("x-flow-report-path").and_then(|v| v.to_str().ok()).unwrap_or_default();
2204
    if !crate::reports::path_ok(path) {
2205
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("report file {path:?}: give its path in X-Flow-Report-Path")));
2206
    }
2207
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2208
    let row =
2209
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
2210
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
2211
    let blob = app
2212
        .repos
2213
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
2214
        .await
2215
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the report file: {e}")))?;
2216
    Ok(Json(serde_json::json!({ "blob": blob.to_hex() })).into_response())
2217
}
2218
2219
/// Parse and keep a job's JUnit report: what it says, or why not.
2220
async fn read_junit(
2221
    app: &Shared,
2222
    row: &db::repos::RepoRow,
2223
    run: &fdb::Run,
2224
    job: &fdb::Job,
2225
    blob: &str,
2226
    bytes: axum::body::Bytes,
2227
) -> Result<String, String> {
2228
    if bytes.len() > crate::junit::MAX_REPORT {
2229
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
2230
    }
2231
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
2232
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
2233
        .await
2234
        .map_err(|e| format!("tests: {e}"))?
2235
        .map_err(|e| format!("tests: {e}"))?;
2236
    let origin = crate::junit::Origin {
2237
        repo_id: row.id,
2238
        run_id: run.id,
2239
        run_number: run.number,
2240
        job_id: job.id,
2241
        job: &job.name,
2242
        bookmark: &run.bookmark,
2243
        state: &run.state,
2244
        blob,
2245
    };
2246
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
2247
    let r = crate::junit::for_run(&app.db, run.id)
2248
        .await
2249
        .map_err(|e| format!("tests: {e}"))?
2250
        .into_iter()
2251
        .find(|r| r.job_id == job.id)
2252
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
2253
    // The `tests` check on the run's state: what fails, and which of those
2254
    // passed in the same job's latest report on the default bookmark, so a
2255
    // proposal names what it broke.
2256
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
2257
    let newly = match &base {
2258
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
2259
        _ => vec![],
2260
    };
2261
    let (status, summary) = if r.broken() == 0 {
2262
        ("passed", format!("{} passed", r.passed))
2263
    } else if let Some(b) = &base {
2264
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
2265
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
2266
        let new = if newly.is_empty() {
2267
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
2268
        } else {
2269
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
2270
        };
2271
        ("failed", format!("{} failing; {new}", r.broken()))
2272
    } else {
2273
        ("failed", format!("{} failing", r.broken()))
2274
    };
2275
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
2276
    if let Ok(c) = db::hooks::upsert_check(
2277
        &app.db,
2278
        row.id,
2279
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
2280
    )
2281
    .await
2282
    {
2283
        let api = crate::api::check_of(&c);
2284
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2285
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2286
    }
2287
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
2288
}
2289
2290
/// Parse and keep a job's coverage report: what it says, or why not.
2291
async fn read_coverage(
2292
    app: &Shared,
2293
    repo: &RepoName,
2294
    row: &db::repos::RepoRow,
2295
    run: &fdb::Run,
2296
    job: &fdb::Job,
2297
    blob: &str,
2298
    bytes: axum::body::Bytes,
2299
) -> Result<String, String> {
2300
    if bytes.len() > crate::coverage::MAX_REPORT {
2301
        return Err(format!(
2302
            "coverage: the report is {} MiB; at most {} MiB is read",
2303
            bytes.len() >> 20,
2304
            crate::coverage::MAX_REPORT >> 20
2305
        ));
2306
    }
2307
    let text = String::from_utf8(bytes.to_vec())
2308
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
2309
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
2310
        .await
2311
        .map_err(|e| format!("coverage: {e}"))?
2312
        .map_err(|e| format!("coverage: {e}"))?;
2313
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
2314
    let in_state: std::collections::HashSet<String> = app
2315
        .repos
2316
        .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect()))
2317
        .await
2318
        .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?;
2319
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
2320
    let origin = crate::coverage::Origin {
2321
        repo_id: row.id,
2322
        run_id: Some(run.id),
2323
        run_number: run.number,
2324
        job_id: Some(job.id),
2325
        uploader: "",
2326
        job: &job.name,
2327
        bookmark: &run.bookmark,
2328
        state: &run.state,
2329
        heads: &heads,
2330
        blob,
2331
        format,
2332
    };
2333
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
2334
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
2335
        return Err("coverage: the report was not kept".into());
2336
    };
2337
    // The `coverage` check on the run's state (#113): against the default
2338
    // bookmark, under the repository's rules.
2339
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
2340
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
2341
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
2342
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
2343
    if let Ok(c) = db::hooks::upsert_check(
2344
        &app.db,
2345
        row.id,
2346
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
2347
    )
2348
    .await
2349
    {
2350
        let api = crate::api::check_of(&c);
2351
        app.bus.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
2352
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
2353
    }
2354
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
2355
    let dropped =
2356
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
2357
    Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files))
2358
}
2359
2360
/// `POST /api/v1/flow/jobs/{id}/finish`.
2361
pub async fn finish(
2362
    State(app): State<Shared>,
2363
    headers: HeaderMap,
2364
    Path(id): Path<i64>,
2365
    Json(f): Json<Finish>,
2366
) -> Result<StatusCode, ApiError> {
2367
    let me = runner_auth(&app, &headers).await?;
2368
    let job = running_job(&app, id).await?;
2369
    let status = match (f.exit_code, &f.error) {
2370
        (Some(0), None) => "passed",
2371
        (Some(_), _) => "failed",
2372
        (None, _) => "error",
2373
    };
2374
    let (receipt, receipt_hash) = match &f.receipt {
2375
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
2376
            // A passing job's receipt names exactly the artifacts uploaded,
2377
            // in name order; a failing one's names none, though it may have
2378
            // kept `when "always"` artifacts (oxygenflow#37).
2379
            let kept: Vec<String> =
2380
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
2381
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
2382
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
2383
            }
2384
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
2385
        }
2386
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
2387
        None => (String::new(), String::new()),
2388
    };
2389
    let error = f.error.clone().unwrap_or_default();
2390
    fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?;
2391
    let changed = fdb::finish(
2392
        &app.db,
2393
        job.id,
2394
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
2395
    )
2396
    .await?;
2397
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
2398
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
2399
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
2400
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
2401
                    Ok((attest, signer)) => (attest, signer, String::new()),
2402
                    Err(e) => {
2403
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
2404
                        (String::new(), String::new(), e)
2405
                    }
2406
                };
2407
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
2408
            }
2409
        }
2410
    }
2411
    report_changed(&app, job.run_id, changed).await?;
2412
    Ok(StatusCode::NO_CONTENT)
2413
}
2414
2415
/// Report the checks of jobs whose status changed.
2416
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
2417
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
2418
        return Ok(());
2419
    };
2420
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
2421
        return Ok(());
2422
    };
2423
    let mut errored_on: Vec<i64> = Vec::new();
2424
    let some_passed = changed.iter().any(|j| j.status == "passed");
2425
    if !changed.is_empty() {
2426
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
2427
    }
2428
    for j in changed {
2429
        let summary = match j.status.as_str() {
2430
            "passed" => match j.reused_from() {
2431
                Some(n) => format!("passed in run #{n} on this state, reused"),
2432
                None => format!("passed in {}", duration(&j)),
2433
            },
2434
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
2435
            "skipped" => "skipped: a dependency did not pass".into(),
2436
            "cancelled" => j.error.clone(),
2437
            _ => format!("could not run: {}", j.error),
2438
        };
2439
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
2440
        if j.status == "error" {
2441
            if let Some(r) = runner_of(app, j.id).await {
2442
                errored_on.push(r);
2443
            }
2444
        }
2445
    }
2446
    errored_on.dedup();
2447
    for r in errored_on {
2448
        drain_if_erroring(app, r).await;
2449
    }
2450
    if some_passed {
2451
        tell_approvers(app, &row, &run).await;
2452
    }
2453
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2454
        if done.status == "failed" && !done.live() {
2455
            tell_failed(app, &row, &done).await;
2456
        }
2457
        // A pass after a failure on the same bookmark: fixed (#183).
2458
        if done.status == "passed" {
2459
            let before: Option<String> = sqlx::query_scalar(
2460
                "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND id < $3 AND status IN ('passed', 'failed')
2461
                 ORDER BY id DESC LIMIT 1",
2462
            )
2463
            .bind(done.repo_id)
2464
            .bind(&done.bookmark)
2465
            .bind(done.id)
2466
            .fetch_optional(&app.db)
2467
            .await?;
2468
            // Or this very run failed and a job of it was run again.
2469
            let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number);
2470
            let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = $1")
2471
                .bind(&url)
2472
                .fetch_one(&app.db)
2473
                .await?;
2474
            if before.as_deref() == Some("failed") || failed_here > 0 {
2475
                tell_fixed(app, &row, &done).await;
2476
            }
2477
        }
2478
    }
2479
    // A passing run of an upstream's default bookmark: consumers following
2480
    // it when green are bumped (Diffusion, #148).
2481
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
2482
        if done.status == "passed" && done.bookmark == row.default_bookmark {
2483
            let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
2484
            let (app, row) = (app.clone(), row.clone());
2485
            tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await });
2486
        }
2487
        // A bump's run passed: a consumer that lands its bumps itself may
2488
        // land it now (docs/diffusion.md §4.2).
2489
        if let Some(path) = done.bookmark.strip_prefix("diffusion/").filter(|_| done.status == "passed") {
2490
            tokio::spawn(crate::diffusion::try_land(app.clone(), row.clone(), path.to_string()));
2491
        }
2492
    }
2493
    Ok(())
2494
}
2495
2496
/// The account OxygenFlow's notices come from, made on first use with no
2497
/// way to sign in.
2498
pub const ACCOUNT: &str = "oxygenflow";
2499
2500
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
2501
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
2502
        return Some(a);
2503
    }
2504
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
2505
}
2506
2507
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
2508
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = $1").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
2509
}
2510
2511
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
2512
/// are told, in their inboxes and by email as they chose, naming the jobs
2513
/// that did not pass.
2514
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2515
    let Some(me) = flow_account(&app.db).await else { return };
2516
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
2517
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
2518
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2519
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2520
    crate::notify::deliver(
2521
        app,
2522
        crate::notify::Event {
2523
            kind: "flow.failed",
2524
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
2525
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2526
            actor: &me,
2527
            repo_id: row.id,
2528
            thread: None,
2529
            mention_text: "",
2530
            direct: pusher.into_iter().map(|a| a.id).collect(),
2531
        },
2532
    )
2533
    .await;
2534
}
2535
2536
/// A run that passes after its bookmark's last one failed (oxygenhub#183):
2537
/// its pusher and the repository's watchers hear it is green again.
2538
async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
2539
    let Some(me) = flow_account(&app.db).await else { return };
2540
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
2541
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
2542
    crate::notify::deliver(
2543
        app,
2544
        crate::notify::Event {
2545
            kind: "flow.fixed",
2546
            title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number),
2547
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
2548
            actor: &me,
2549
            repo_id: row.id,
2550
            thread: None,
2551
            mention_text: "",
2552
            direct: pusher.into_iter().map(|a| a.id).collect(),
2553
        },
2554
    )
2555
    .await;
2556
}
2557
2558
/// A runner whose last jobs all ended in infrastructure errors is drained,
2559
/// so it stops taking jobs it fails before running them, and the
2560
/// administrators are told (oxygenflow#30).
2561
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
2562
    let n = app.config.flow.drain_after_errors;
2563
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
2564
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
2565
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
2566
        return;
2567
    }
2568
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
2569
    let Some(me) = flow_account(&app.db).await else { return };
2570
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default();
2571
    crate::notify::deliver(
2572
        app,
2573
        crate::notify::Event {
2574
            kind: "flow.runner_drained",
2575
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
2576
            url: format!("/admin/runners/{runner_id}"),
2577
            actor: &me,
2578
            repo_id: 0,
2579
            thread: None,
2580
            mention_text: "",
2581
            direct: admins,
2582
        },
2583
    )
2584
    .await;
2585
}
2586
2587
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
2588
/// Settings, else from `hub.toml`.
2589
pub struct Limits {
2590
    /// For a job whose pipeline names none.
2591
    pub default_s: u64,
2592
    /// The most a pipeline may ask for.
2593
    pub max_s: u64,
2594
    /// Grace past a job's limit before the hub gives it up: a runner stops
2595
    /// a job at its limit and reports it within moments, so one still
2596
    /// running this long after is one the runner no longer reports (it was
2597
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
2598
    pub grace_ms: i64,
2599
}
2600
2601
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
2602
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
2603
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
2604
2605
pub async fn limits(app: &Shared) -> Limits {
2606
    let minutes = |key: &'static str| async move {
2607
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
2608
    };
2609
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
2610
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
2611
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
2612
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
2613
}
2614
2615
/// End every job still running past its time limit plus the grace:
2616
/// it is marked an error with the reason, the jobs that depended on it are
2617
/// skipped and its run settled, as when a runner reports an error.
2618
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
2619
    let limits = limits(app).await;
2620
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
2621
    let n = lost.len();
2622
    for job in lost {
2623
        let error = format!(
2624
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
2625
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
2626
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
2627
        );
2628
        let changed =
2629
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2630
                .await?;
2631
        tracing::warn!("flow: job {} {error}", job.id);
2632
        report_changed(app, job.run_id, changed).await?;
2633
    }
2634
    Ok(n)
2635
}
2636
2637
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
2638
/// it restarted since claiming them, or never received the claim. A
2639
/// runner claims one job at a time, so a job it holds is always listed.
2640
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
2641
    for job in fdb::running_on(&app.db, runner.id).await? {
2642
        if holding.contains(&job.id) {
2643
            continue;
2644
        }
2645
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
2646
        let changed =
2647
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
2648
                .await?;
2649
        tracing::warn!("flow: job {} {error}", job.id);
2650
        report_changed(app, job.run_id, changed).await?;
2651
    }
2652
    Ok(())
2653
}
2654
2655
/// Start the scheduled runs due at `when` (oxygenflow#24): on each
2656
/// repository that runs Flow, the jobs of its default bookmark's pipeline
2657
/// with a `schedule` line due then. Returns how many runs started.
2658
pub async fn run_due(app: &Shared, when: flow_core::pipeline::When) -> usize {
2659
    let Ok(rows) = db::repos::list(&app.db).await else { return 0 };
2660
    let mut started = 0;
2661
    for row in rows.into_iter().filter(|r| enabled(app, r)) {
2662
        let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue };
2663
        let b = row.default_bookmark.clone();
2664
        let found = app
2665
            .repos
2666
            .read(&repo, move |r, _| {
2667
                let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
2668
                Ok(Some((st, r.store().get_state(&st)?.heads)))
2669
            })
2670
            .await;
2671
        let Ok(Some((state, heads))) = found else { continue };
2672
        if heads.is_empty() {
2673
            continue;
2674
        }
2675
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
2676
            Ok(Some(_)) => started += 1,
2677
            Ok(None) => {}
2678
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
2679
        }
2680
    }
2681
    started
2682
}
2683
2684
/// The hub's clock as a [`When`](flow_core::pipeline::When).
2685
pub fn when_now() -> flow_core::pipeline::When {
2686
    let now = time::OffsetDateTime::now_utc();
2687
    flow_core::pipeline::When {
2688
        minute: now.minute() as u32,
2689
        hour: now.hour() as u32,
2690
        day: now.day() as u32,
2691
        month: u8::from(now.month()) as u32,
2692
        weekday: now.weekday().number_days_from_sunday() as u32,
2693
    }
2694
}
2695
2696
/// Start the scheduled runs due each minute, once a minute.
2697
pub async fn scheduler(app: Shared) {
2698
    let mut last = None;
2699
    loop {
2700
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
2701
        let minute = time::OffsetDateTime::now_utc().unix_timestamp() / 60;
2702
        if last == Some(minute) {
2703
            continue;
2704
        }
2705
        last = Some(minute);
2706
        let n = run_due(&app, when_now()).await;
2707
        if n > 0 {
2708
            tracing::info!("flow: {n} scheduled run(s) started");
2709
        }
2710
    }
2711
}
2712
2713
/// Look for lost jobs once a minute.
2714
pub async fn reaper(app: Shared) {
2715
    loop {
2716
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2717
        if let Err(e) = requeue_lapsed(&app).await {
2718
            tracing::warn!("flow: looking for lapsed leases: {e}");
2719
        }
2720
        if let Err(e) = reap_lost_jobs(&app).await {
2721
            tracing::warn!("flow: looking for lost jobs: {e}");
2722
        }
2723
        match expire_approvals(&app, db::now_ms()).await {
2724
            Ok(n) if n > 0 => tracing::info!("flow: {n} approvals expired"),
2725
            Ok(_) => {}
2726
            Err(e) => tracing::warn!("flow: expiring approvals: {e}"),
2727
        }
2728
    }
2729
}
2730
2731
/// Check a runner's signed receipt for a job of `run` and keep it in the
2732
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
2733
/// name, or why it was not kept. It must be the job's receipt, for the
2734
/// run's state and repository, signed by the key the runner's token first
2735
/// signed with.
2736
pub async fn keep_receipt(
2737
    app: &Shared,
2738
    runner: &db::runners::Runner,
2739
    row: &db::repos::RepoRow,
2740
    run: &fdb::Run,
2741
    receipt: &flow_core::Receipt,
2742
    signed_hex: &str,
2743
) -> Result<(String, String), String> {
2744
    use oxy_core::receipt::SignedReceipt;
2745
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
2746
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
2747
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
2748
    if sr.receipt.hash().0 != receipt.hash() {
2749
        return Err("it signs another receipt than the job's".into());
2750
    }
2751
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
2752
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
2753
    if sr.state() != state {
2754
        return Err("it is for another state than the run's".into());
2755
    }
2756
    let signer = match &sr.certificate {
2757
        // Signed by a short-lived key this hub certified for this job
2758
        // (keyless, #241): the certificate stands for the runner's key.
2759
        Some(c) => {
2760
            let cert = &c.cert.cert;
2761
            if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key {
2762
                return Err("its certificate was not issued by this hub".into());
2763
            }
2764
            let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark);
2765
            if cert.subject != subject {
2766
                return Err(format!("its certificate is for {}, not {subject}", cert.subject));
2767
            }
2768
            if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) {
2769
                return Err("its certificate is for another run or job".into());
2770
            }
2771
            // Now is when this hub accepts it: a time the runner did not choose.
2772
            if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) {
2773
                return Err("its certificate does not cover now: certify the key just before signing the receipt".into());
2774
            }
2775
            format!("{} (short-lived key)", runner.name)
2776
        }
2777
        None => {
2778
            let key = sr.runner.hash().to_hex();
2779
            let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
2780
            if bound != key {
2781
                return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
2782
            }
2783
            sr.runner.name.clone()
2784
        }
2785
    };
2786
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
2787
    let (rid, signers) = (sr.attest.repo, sr.sigs.iter().map(|s| s.key).collect::<Vec<_>>());
2788
    let cert = sr.certificate.as_ref().map(|c| (c.cert.cert.hash(), c.cert.cert.issuer));
2789
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
2790
    // The transparency log (#238): the receipt, and a short-lived key's
2791
    // certificate beside it.
2792
    let mut entries = vec![crate::tlog::Pending { kind: "receipt", repo: rid, repo_row: Some(row.id), object: attest, signers }];
2793
    if let Some((c, issuer)) = cert {
2794
        entries.push(crate::tlog::Pending { kind: "certificate", repo: rid, repo_row: Some(row.id), object: c, signers: vec![issuer] });
2795
    }
2796
    crate::tlog::log(app, entries).await;
2797
    Ok((attest.to_hex(), signer))
2798
}
2799
2800
/// A job's wall time, as the pages show it.
2801
pub fn duration(j: &fdb::Job) -> String {
2802
    match (j.started_ms, j.finished_ms) {
2803
        (Some(a), Some(b)) => human_ms(b - a),
2804
        (Some(a), None) => human_ms(db::now_ms() - a),
2805
        _ => "–".into(),
2806
    }
2807
}
2808
2809
pub fn human_ms(ms: i64) -> String {
2810
    let ms = ms.max(0);
2811
    if ms < 1000 {
2812
        format!("{ms} ms")
2813
    } else if ms < 60_000 {
2814
        format!("{:.1} s", ms as f64 / 1000.0)
2815
    } else {
2816
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
2817
    }
2818
}
2819
2820
/// Where a live run stands among the runners' work: what its viewers may
2821
/// know of it. Jobs of other repositories are named only when those are
2822
/// public; the rest are counted.
2823
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2824
pub struct QueueInfo {
2825
    /// 1-based place of the run's first queued job among all queued jobs.
2826
    pub position: Option<usize>,
2827
    /// Jobs running now, and queued jobs ahead of the run's first one.
2828
    pub running: Vec<String>,
2829
    pub ahead: Vec<String>,
2830
    /// Of those, jobs in repositories the page may not name.
2831
    pub hidden: usize,
2832
}
2833
2834
impl QueueInfo {
2835
    /// "3rd in line" and the like.
2836
    pub fn place(&self) -> Option<String> {
2837
        self.position.map(|n| {
2838
            let suffix = match (n % 10, n % 100) {
2839
                (1, 11) | (2, 12) | (3, 13) => "th",
2840
                (1, _) => "st",
2841
                (2, _) => "nd",
2842
                (3, _) => "rd",
2843
                _ => "th",
2844
            };
2845
            format!("{n}{suffix} in line")
2846
        })
2847
    }
2848
}
2849
2850
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
2851
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
2852
    let mut info = QueueInfo::default();
2853
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
2854
    info.position = first.map(|i| i + 1);
2855
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
2856
    let mut queued_seen = 0;
2857
    for e in queue {
2858
        if e.run_id == run_id {
2859
            continue;
2860
        }
2861
        let ahead = match e.status.as_str() {
2862
            "running" => true,
2863
            _ => {
2864
                queued_seen += 1;
2865
                first.is_some_and(|f| queued_seen <= f)
2866
            }
2867
        };
2868
        if !ahead {
2869
            continue;
2870
        }
2871
        if e.repo_id != repo_id && e.visibility != "public" {
2872
            info.hidden += 1;
2873
        } else if e.status == "running" {
2874
            info.running.push(label(e));
2875
        } else {
2876
            info.ahead.push(label(e));
2877
        }
2878
    }
2879
    info
2880
}
2881
2882
#[cfg(test)]
2883
mod queue_tests {
2884
    use super::*;
2885
2886
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
2887
        fdb::QueueEntry {
2888
            job_id,
2889
            run_id,
2890
            repo_id,
2891
            owner: "o".into(),
2892
            name: format!("r{repo_id}"),
2893
            visibility: vis.into(),
2894
            run_number: run_id,
2895
            job: job.into(),
2896
            status: status.into(),
2897
            runner: String::new(),
2898
            spec: String::new(),
2899
            sandbox: String::new(),
2900
            started_ms: None,
2901
            priority_rank: 2,
2902
            priority_note: String::new(),
2903
        }
2904
    }
2905
2906
    #[test]
2907
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
2908
        // Running first, then queued in claim order (as `fdb::queue` returns them).
2909
        let q = vec![
2910
            e(1, 10, 1, "public", "build", "running"),
2911
            e(2, 11, 2, "private", "test", "running"),
2912
            e(3, 10, 1, "public", "test", "queued"),
2913
            e(4, 12, 3, "private", "lint", "queued"),
2914
            e(5, 13, 4, "public", "build", "queued"),
2915
            e(6, 13, 4, "public", "test", "queued"),
2916
            e(7, 14, 5, "public", "late", "queued"),
2917
        ];
2918
        let info = queue_info(&q, 4, 13);
2919
        assert_eq!(info.position, Some(3));
2920
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
2921
        assert_eq!(info.running, ["o/r1 #10 build"]);
2922
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
2923
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
2924
        // A private repository's own jobs are named to its viewers.
2925
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
2926
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
2927
        // A run with nothing queued has no place.
2928
        assert_eq!(queue_info(&q, 1, 99).position, None);
2929
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
2930
        assert_eq!(
2931
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
2932
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
2933
        );
2934
    }
2935
}
2936
2937
#[cfg(test)]
2938
mod placement_tests {
2939
    use super::*;
2940
2941
    #[test]
2942
    fn a_waiting_job_says_what_each_runner_lacks() {
2943
        use crate::db::runners::Runner;
2944
        let rootfs = "ab".repeat(32);
2945
        let job = fdb::Job {
2946
            id: 7,
2947
            runtime: "syscall".into(),
2948
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
2949
            spec: serde_json::json!({ "name": "build" }).to_string(),
2950
            ..Default::default()
2951
        };
2952
        let now = db::now_ms();
2953
        let runner = |id: i64, name: &str| Runner {
2954
            id,
2955
            name: name.into(),
2956
            state: "active".into(),
2957
            last_seen_ms: Some(now),
2958
            arch: "x86_64".into(),
2959
            tiers: "microvm,syscall".into(),
2960
            images: serde_json::to_string(&[&rootfs]).unwrap(),
2961
            memory_mib: Some(16384),
2962
            cpus: Some(8),
2963
            ..Default::default()
2964
        };
2965
        let archs = std::collections::HashMap::new();
2966
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
2967
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
2968
        };
2969
        assert_eq!(why(&[], &[]), "no runner is online");
2970
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
2971
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
2972
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
2973
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
2974
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
2975
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
2976
        assert_eq!(
2977
            why(&[microvm_only, small, other, offline], &[]),
2978
            "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"
2979
        );
2980
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
2981
        assert_eq!(
2982
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
2983
            "no runner can take it: narvi is riscv64; the job needs x86_64"
2984
        );
2985
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
2986
        assert_eq!(why(std::slice::from_ref(&without), &[]), "no runner can take it: new does not hold image abababababab");
2987
        // A WASM job has no image: holding none is no reason to wait.
2988
        let wasm = fdb::Job { runtime: "wasm".into(), sandbox: r#"{"memory_mib":256}"#.into(), ..job.clone() };
2989
        let wasm_runner = Runner { tiers: "microvm,wasm".into(), ..without };
2990
        assert_eq!(why_waiting(&wasm, "andy", 1, &[wasm_runner], &archs, &[], now, &Default::default()), "", "it only waits its turn");
2991
        let d = fdb::DeclineRow {
2992
            job_id: 7,
2993
            runner_id: 1,
2994
            runner: "earth-1".into(),
2995
            reason: "only 512 MiB of memory is free here".into(),
2996
            ms: now,
2997
        };
2998
        assert_eq!(
2999
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
3000
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
3001
        );
3002
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
3003
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
3004
    }
3005
}