OXYGENHUB
# a3c8288b0141 oxygenhub / crates / hub / src / flow.rs
22.5 KiB · Rust · last changed Keep and serve Flow job artifacts 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
            // The receipt names exactly the artifacts uploaded, in name order.
404
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
405
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
406
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
407
            }
408
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
409
        }
410
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
411
        None => (String::new(), String::new()),
412
    };
413
    let error = f.error.clone().unwrap_or_default();
414
    let changed = fdb::finish(
415
        &app.db,
416
        job.id,
417
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
418
    )
419
    .await?;
420
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
421
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
422
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
423
                let (attest, signer, note) = match keep_receipt(&app, &token, &row, &run, r, signed).await {
424
                    Ok((attest, signer)) => (attest, signer, String::new()),
425
                    Err(e) => {
426
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
427
                        (String::new(), String::new(), e)
428
                    }
429
                };
430
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
431
            }
432
            for j in changed {
433
                let summary = match j.status.as_str() {
434
                    "passed" => format!("passed in {}", duration(&j)),
435
                    "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
436
                    "skipped" => "skipped: a dependency did not pass".into(),
437
                    _ => format!("could not run: {}", j.error),
438
                };
439
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
440
            }
441
        }
442
    }
443
    Ok(StatusCode::NO_CONTENT)
444
}
445
446
/// Check a runner's signed receipt for a job of `run` and keep it in the
447
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
448
/// name, or why it was not kept. It must be the job's receipt, for the
449
/// run's state and repository, signed by the key the runner's token first
450
/// signed with.
451
pub async fn keep_receipt(
452
    app: &Shared,
453
    token_hash: &str,
454
    row: &db::repos::RepoRow,
455
    run: &fdb::Run,
456
    receipt: &flow_core::Receipt,
457
    signed_hex: &str,
458
) -> Result<(String, String), String> {
459
    use oxy_core::receipt::SignedReceipt;
460
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
461
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
462
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
463
    if sr.receipt.hash().0 != receipt.hash() {
464
        return Err("it signs another receipt than the job's".into());
465
    }
466
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
467
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
468
    if sr.state() != state {
469
        return Err("it is for another state than the run's".into());
470
    }
471
    let key = sr.runner.hash().to_hex();
472
    let bound = fdb::bind_runner_key(&app.db, token_hash, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
473
    if bound != key {
474
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
475
    }
476
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
477
    let signer = sr.runner.name.clone();
478
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
479
    Ok((attest.to_hex(), signer))
480
}
481
482
/// A job's wall time, as the pages show it.
483
pub fn duration(j: &fdb::Job) -> String {
484
    match (j.started_ms, j.finished_ms) {
485
        (Some(a), Some(b)) => human_ms(b - a),
486
        (Some(a), None) => human_ms(db::now_ms() - a),
487
        _ => "–".into(),
488
    }
489
}
490
491
pub fn human_ms(ms: i64) -> String {
492
    let ms = ms.max(0);
493
    if ms < 1000 {
494
        format!("{ms} ms")
495
    } else if ms < 60_000 {
496
        format!("{:.1} s", ms as f64 / 1000.0)
497
    } else {
498
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
499
    }
500
}