OXYGENHUB
# d29070faef66 oxygenhub / crates / hub / src / flow.rs
26.7 KiB · Rust · last changed Flow: a failing job's receipt may leave kept artifacts out 1 month ago source blame history raw
1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
36
        return;
37
    }
38
    if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await {
39
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
40
    }
41
}
42
43
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
44
/// moving it. Returns the new run's number.
45
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
46
    if !enabled(app, row) {
47
        return Err("OxygenFlow is not enabled for this repository".into());
48
    }
49
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
50
    let b = bookmark.to_string();
51
    let found = app
52
        .repos
53
        .read(&repo, move |r, _| {
54
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
55
            Ok(Some((st, r.store().get_state(&st)?.heads)))
56
        })
57
        .await
58
        .map_err(|e| e.to_string())?;
59
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
60
    if heads.is_empty() {
61
        return Err(format!("{bookmark} has no changes yet"));
62
    }
63
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
64
        .await?
65
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
66
}
67
68
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
69
/// run whose checks replace the old ones. Returns the new run's number.
70
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
71
    if !enabled(app, row) {
72
        return Err("OxygenFlow is not enabled for this repository".into());
73
    }
74
    if run.live() {
75
        return Err(format!("run #{} is still {}", run.number, run.status));
76
    }
77
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
78
    if heads.is_empty() {
79
        return Err("the run names no changes".into());
80
    }
81
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
82
        .await?
83
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
84
}
85
86
/// Start a run of the pipeline in the state `heads` make (whose state
87
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
88
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
89
/// whose access the run's subrepo reads are checked against. Returns the
90
/// run's number, or `None` when the state has no pipeline. The caller
91
/// checks that Flow is enabled.
92
pub async fn start_run(
93
    app: &Shared,
94
    row: &db::repos::RepoRow,
95
    bookmark: &str,
96
    state: &str,
97
    heads: &[ChangeHash],
98
    actor: &str,
99
    trigger: &str,
100
) -> Result<Option<i64>, String> {
101
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
102
    let h = heads.to_vec();
103
    let text = app
104
        .repos
105
        .read(&repo, move |r, cache| {
106
            let loaded = cache.load(r, &h)?;
107
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
108
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
109
                _ => None,
110
            })
111
        })
112
        .await
113
        .map_err(|e| format!("reading the pipeline: {e}"))?;
114
    let Some(text) = text else { return Ok(None) };
115
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
116
    hex.sort();
117
    let heads_s = hex.join("+");
118
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
119
    let id = match Pipeline::parse(&text) {
120
        Err(problems) => {
121
            let msg = problems.to_string();
122
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
123
        }
124
        Ok(p) => {
125
            let depths = p.depths();
126
            let jobs: Vec<fdb::NewJob> = p
127
                .order()
128
                .into_iter()
129
                .map(|j| {
130
                    let sb = &p.sandboxes[&j.sandbox];
131
                    fdb::NewJob {
132
                        name: j.name.clone(),
133
                        depth: depths[j.name.as_str()] as i64,
134
                        depends: j.depends_on.clone(),
135
                        spec: serde_json::to_string(j).unwrap_or_default(),
136
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
137
                        runtime: sb.runtime.as_str().into(),
138
                    }
139
                })
140
                .collect();
141
            let hash = hex::encode(p.hash());
142
            let id =
143
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
144
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
145
                for j in &jobs {
146
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
147
                }
148
            }
149
            id
150
        }
151
    };
152
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
153
    Ok(Some(run.number))
154
}
155
156
/// Record a job's check; it links to the run's page by the run's number.
157
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
158
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
159
    let name = format!("flow/{job}");
160
    let c = db::hooks::upsert_check(
161
        &app.db,
162
        row.id,
163
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
164
    )
165
    .await;
166
    if let Ok(c) = c {
167
        let api = crate::api::check_of(&c);
168
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
169
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
170
    }
171
}
172
173
/// The check status a job's status shows as.
174
fn check_status(job_status: &str) -> &'static str {
175
    match job_status {
176
        "queued" => "queued",
177
        "running" => "running",
178
        "passed" => "passed",
179
        "skipped" => "skipped",
180
        _ => "failed",
181
    }
182
}
183
184
/// The runner's token, checked, as the BLAKE3 hex that names it.
185
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
186
    let Some(t) = oxy_server::bearer_token(headers) else {
187
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
188
    };
189
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
190
        Ok(blake3::hash(t.as_bytes()).to_hex().to_string())
191
    } else {
192
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
193
    }
194
}
195
196
/// `POST /api/v1/flow/claim`.
197
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
198
    runner_auth(&app, &headers)?;
199
    let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
200
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
201
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
202
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else {
203
        return Ok(StatusCode::NO_CONTENT.into_response());
204
    };
205
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
206
    let row =
207
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
208
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
209
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
210
    let claim = Claim {
211
        job_id: job.id,
212
        run_id: run.id,
213
        repo: format!("{}/{}", row.owner, row.name),
214
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
215
        token: secret,
216
        heads: run.head_list().into_iter().map(String::from).collect(),
217
        pipeline_hash: run.pipeline_hash.clone(),
218
        job: serde_json::from_str(&job.spec).map_err(bad)?,
219
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
220
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
221
        subrepos: vec![],
222
        state: run.state.clone(),
223
        bookmark: run.bookmark.clone(),
224
        run_number: run.number,
225
    };
226
    let mut claim = claim;
227
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
228
    if !ids.is_empty() {
229
        fdb::set_also_read(&app.db, job.id, &ids).await?;
230
    }
231
    claim.subrepos = inputs;
232
    Ok(Json(claim).into_response())
233
}
234
235
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
236
async fn pins_in(
237
    app: &Shared,
238
    name: crate::repos::RepoName,
239
    heads: Vec<oxy_core::hash::ChangeHash>,
240
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
241
    app.repos
242
        .read(&name, move |repo, cache| {
243
            let loaded = cache.load(repo, &heads)?;
244
            let mut out = Vec::new();
245
            for (path, f) in &loaded.snapshot.files {
246
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
247
                    continue;
248
                }
249
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
250
                    if let Some(b) = blobs.first() {
251
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
252
                            .read_blob(b)
253
                            .map_err(crate::repos::RepoError::Oxy)
254
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
255
                        {
256
                            out.push((path.clone(), id, heads));
257
                        }
258
                    }
259
                }
260
            }
261
            Ok(out)
262
        })
263
        .await
264
        .unwrap_or_default()
265
}
266
267
/// How deep subrepos of subrepos are followed.
268
const MAX_SUBREPO_DEPTH: usize = 3;
269
270
/// The Oxygen subrepos the run's state pins, and those their states pin in
271
/// turn (parents first, paths joined), that live on this hub and that the
272
/// run's pusher may read: what the job's workspace needs beside its own
273
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
274
/// then fails on the missing files with an ordinary error.
275
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
276
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
277
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
278
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
279
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
280
    let mut inputs = Vec::new();
281
    let mut ids = Vec::new();
282
    // (path prefix, repository, heads, depth), parents before children.
283
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
284
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
285
        if depth == MAX_SUBREPO_DEPTH {
286
            continue;
287
        }
288
        for (path, id, pinned) in pins_in(app, name, heads).await {
289
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
290
            for other in &rows {
291
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
292
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
293
                if !matches {
294
                    continue;
295
                }
296
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
297
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
298
                    inputs.push(flow_core::api::SubrepoInput {
299
                        path: path.clone(),
300
                        repo: format!("{}/{}", other.owner, other.name),
301
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
302
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
303
                    });
304
                    if !ids.contains(&other.id) {
305
                        ids.push(other.id);
306
                    }
307
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
308
                }
309
                break;
310
            }
311
        }
312
    }
313
    (inputs, ids)
314
}
315
316
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
317
    match fdb::job(&app.db, id).await? {
318
        Some(j) if j.status == "running" => Ok(j),
319
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
320
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
321
    }
322
}
323
324
/// `POST /api/v1/flow/jobs/{id}/log`.
325
pub async fn log(
326
    State(app): State<Shared>,
327
    headers: HeaderMap,
328
    Path(id): Path<i64>,
329
    Json(mut batch): Json<LogBatch>,
330
) -> Result<StatusCode, ApiError> {
331
    runner_auth(&app, &headers)?;
332
    let job = running_job(&app, id).await?;
333
    for r in &mut batch.records {
334
        if r.text.len() > MAX_RECORD_TEXT {
335
            let mut cut = MAX_RECORD_TEXT;
336
            while !r.text.is_char_boundary(cut) {
337
                cut -= 1;
338
            }
339
            r.text.truncate(cut);
340
        }
341
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
342
            r.stream = "console".into();
343
        }
344
    }
345
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
346
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
347
        tracing::info!("flow: job {} reached the log limit", job.id);
348
    }
349
    Ok(StatusCode::NO_CONTENT)
350
}
351
352
/// Largest artifact the hub keeps; the runner refuses larger ones first.
353
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
354
355
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
356
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
357
/// repository. Answers `{"blob"}`.
358
pub async fn artifact(
359
    State(app): State<Shared>,
360
    headers: HeaderMap,
361
    Path((id, name)): Path<(i64, String)>,
362
    body: axum::body::Bytes,
363
) -> Result<Response, ApiError> {
364
    runner_auth(&app, &headers)?;
365
    let job = running_job(&app, id).await?;
366
    let spec: flow_core::Job =
367
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
368
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
369
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
370
    };
371
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
372
    let row =
373
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
374
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
375
    let content = blake3::hash(&body).to_hex().to_string();
376
    let size = body.len() as i64;
377
    let blob = app
378
        .repos
379
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
380
        .await
381
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
382
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
383
    fdb::add_artifact(&app.db, &a).await?;
384
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
385
}
386
387
/// `POST /api/v1/flow/jobs/{id}/finish`.
388
pub async fn finish(
389
    State(app): State<Shared>,
390
    headers: HeaderMap,
391
    Path(id): Path<i64>,
392
    Json(f): Json<Finish>,
393
) -> Result<StatusCode, ApiError> {
394
    let token = runner_auth(&app, &headers)?;
395
    let job = running_job(&app, id).await?;
396
    let status = match (f.exit_code, &f.error) {
397
        (Some(0), None) => "passed",
398
        (Some(_), _) => "failed",
399
        (None, _) => "error",
400
    };
401
    let (receipt, receipt_hash) = match &f.receipt {
402
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
403
            // A passing job's receipt names exactly the artifacts uploaded,
404
            // in name order; a failing one's names none, though it may have
405
            // kept `when "always"` artifacts (oxygenflow#37).
406
            let kept: Vec<String> =
407
                if r.exit_code == 0 { fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect() } else { vec![] };
408
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
409
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
410
            }
411
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
412
        }
413
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
414
        None => (String::new(), String::new()),
415
    };
416
    let error = f.error.clone().unwrap_or_default();
417
    let changed = fdb::finish(
418
        &app.db,
419
        job.id,
420
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
421
    )
422
    .await?;
423
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
424
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
425
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
426
                let (attest, signer, note) = match keep_receipt(&app, &token, &row, &run, r, signed).await {
427
                    Ok((attest, signer)) => (attest, signer, String::new()),
428
                    Err(e) => {
429
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
430
                        (String::new(), String::new(), e)
431
                    }
432
                };
433
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
434
            }
435
            for j in changed {
436
                let summary = match j.status.as_str() {
437
                    "passed" => format!("passed in {}", duration(&j)),
438
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
439
                    "skipped" => "skipped: a dependency did not pass".into(),
440
                    _ => format!("could not run: {}", j.error),
441
                };
442
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
443
            }
444
        }
445
    }
446
    Ok(StatusCode::NO_CONTENT)
447
}
448
449
/// Check a runner's signed receipt for a job of `run` and keep it in the
450
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
451
/// name, or why it was not kept. It must be the job's receipt, for the
452
/// run's state and repository, signed by the key the runner's token first
453
/// signed with.
454
pub async fn keep_receipt(
455
    app: &Shared,
456
    token_hash: &str,
457
    row: &db::repos::RepoRow,
458
    run: &fdb::Run,
459
    receipt: &flow_core::Receipt,
460
    signed_hex: &str,
461
) -> Result<(String, String), String> {
462
    use oxy_core::receipt::SignedReceipt;
463
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
464
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
465
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
466
    if sr.receipt.hash().0 != receipt.hash() {
467
        return Err("it signs another receipt than the job's".into());
468
    }
469
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
470
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
471
    if sr.state() != state {
472
        return Err("it is for another state than the run's".into());
473
    }
474
    let key = sr.runner.hash().to_hex();
475
    let bound = fdb::bind_runner_key(&app.db, token_hash, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
476
    if bound != key {
477
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
478
    }
479
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
480
    let signer = sr.runner.name.clone();
481
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
482
    Ok((attest.to_hex(), signer))
483
}
484
485
/// A job's wall time, as the pages show it.
486
pub fn duration(j: &fdb::Job) -> String {
487
    match (j.started_ms, j.finished_ms) {
488
        (Some(a), Some(b)) => human_ms(b - a),
489
        (Some(a), None) => human_ms(db::now_ms() - a),
490
        _ => "–".into(),
491
    }
492
}
493
494
pub fn human_ms(ms: i64) -> String {
495
    let ms = ms.max(0);
496
    if ms < 1000 {
497
        format!("{ms} ms")
498
    } else if ms < 60_000 {
499
        format!("{:.1} s", ms as f64 / 1000.0)
500
    } else {
501
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
502
    }
503
}
504
505
/// Where a live run stands among the runners' work: what its viewers may
506
/// know of it. Jobs of other repositories are named only when those are
507
/// public; the rest are counted.
508
#[derive(Clone, Debug, Default, PartialEq, Eq)]
509
pub struct QueueInfo {
510
    /// 1-based place of the run's first queued job among all queued jobs.
511
    pub position: Option<usize>,
512
    /// Jobs running now, and queued jobs ahead of the run's first one.
513
    pub running: Vec<String>,
514
    pub ahead: Vec<String>,
515
    /// Of those, jobs in repositories the page may not name.
516
    pub hidden: usize,
517
}
518
519
impl QueueInfo {
520
    /// "3rd in line" and the like.
521
    pub fn place(&self) -> Option<String> {
522
        self.position.map(|n| {
523
            let suffix = match (n % 10, n % 100) {
524
                (1, 11) | (2, 12) | (3, 13) => "th",
525
                (1, _) => "st",
526
                (2, _) => "nd",
527
                (3, _) => "rd",
528
                _ => "th",
529
            };
530
            format!("{n}{suffix} in line")
531
        })
532
    }
533
}
534
535
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
536
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
537
    let mut info = QueueInfo::default();
538
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
539
    info.position = first.map(|i| i + 1);
540
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
541
    let mut queued_seen = 0;
542
    for e in queue {
543
        if e.run_id == run_id {
544
            continue;
545
        }
546
        let ahead = match e.status.as_str() {
547
            "running" => true,
548
            _ => {
549
                queued_seen += 1;
550
                first.is_some_and(|f| queued_seen <= f)
551
            }
552
        };
553
        if !ahead {
554
            continue;
555
        }
556
        if e.repo_id != repo_id && e.visibility != "public" {
557
            info.hidden += 1;
558
        } else if e.status == "running" {
559
            info.running.push(label(e));
560
        } else {
561
            info.ahead.push(label(e));
562
        }
563
    }
564
    info
565
}
566
567
#[cfg(test)]
568
mod queue_tests {
569
    use super::*;
570
571
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
572
        fdb::QueueEntry {
573
            job_id,
574
            run_id,
575
            repo_id,
576
            owner: "o".into(),
577
            name: format!("r{repo_id}"),
578
            visibility: vis.into(),
579
            run_number: run_id,
580
            job: job.into(),
581
            status: status.into(),
582
        }
583
    }
584
585
    #[test]
586
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
587
        // Running first, then queued in claim order (as `fdb::queue` returns them).
588
        let q = vec![
589
            e(1, 10, 1, "public", "build", "running"),
590
            e(2, 11, 2, "private", "test", "running"),
591
            e(3, 10, 1, "public", "test", "queued"),
592
            e(4, 12, 3, "private", "lint", "queued"),
593
            e(5, 13, 4, "public", "build", "queued"),
594
            e(6, 13, 4, "public", "test", "queued"),
595
            e(7, 14, 5, "public", "late", "queued"),
596
        ];
597
        let info = queue_info(&q, 4, 13);
598
        assert_eq!(info.position, Some(3));
599
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
600
        assert_eq!(info.running, ["o/r1 #10 build"]);
601
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
602
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
603
        // A private repository's own jobs are named to its viewers.
604
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
605
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
606
        // A run with nothing queued has no place.
607
        assert_eq!(queue_info(&q, 1, 99).position, None);
608
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
609
        assert_eq!(
610
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
611
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
612
        );
613
    }
614
}