Keep and serve Flow job artifacts a3c8288b0141
The runner holding a job uploads each artifact the job declares; the hub keeps it as a blob of the run's repository and records its path, size and BLAKE3. finish refuses a receipt whose outputs are not exactly those blobs. Run pages list artifacts and link their downloads. The oxygenflow pin moves to artifacts. closes #38
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 07:59 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a3c8288b014138bff6296757c7e494d29e21a102b3f172763effe6f79672dc34- Depends on
- 380996db84e5 3f4b0ea6eabf 42fa95540468 736f6813543d 93be9ce4653e cc19697f8871 d23464c0ac60 ef20fdc14e8b
- Depended on by
- e4203e141e90 ab4df6aae179 16ee5d19d220 97fc47fee9cf 681db69eba17 076c7a7c86b1 5d90e0e045e2 cd437c4f5f8d a36bab6b9ffc 4597750166e7 0b203bbf7cc3 d29070faef66 554ccacfc123 4d2e44d79b03 4ac01a732552 2a83f5303458 b6b7e4cf6b24 df940d75cd3d 10708ab8f54d
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 15 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0022_flow_artifacts.sql +14 −0
| 1 | + | -- A job's artifacts (OxygenFlow spec §5.2): each kept as a blob of the |
|
| 2 | + | -- run's repository, with its size and the BLAKE3 of its bytes, which is |
|
| 3 | + | -- how a release asset finds the receipt that built it. |
|
| 4 | + | CREATE TABLE flow_artifacts ( |
|
| 5 | + | job_id INTEGER NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 6 | + | name TEXT NOT NULL, |
|
| 7 | + | path TEXT NOT NULL, |
|
| 8 | + | blob TEXT NOT NULL, |
|
| 9 | + | size INTEGER NOT NULL, |
|
| 10 | + | content TEXT NOT NULL, |
|
| 11 | + | created_ms INTEGER NOT NULL, |
|
| 12 | + | PRIMARY KEY (job_id, name) |
|
| 13 | + | ); |
|
| 14 | + | CREATE INDEX flow_artifacts_content ON flow_artifacts(content); |
modified crates/hub/src/api/mod.rs +4 −0
| … | 27 unchanged lines |
||
| 28 | 28 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 29 | 29 | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 30 | 30 | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| 31 | + | .route( |
|
| 32 | + | "/flow/jobs/{id}/artifacts/{name}", |
|
| 33 | + | post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| 34 | + | ) |
|
| 31 | 35 | .route("/projects/{owner}", get(projects::list).post(projects::create)) |
|
| 32 | 36 | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
|
| 33 | 37 | .route("/projects/{owner}/{number}/cards", post(projects::add_card)) |
|
| … | 1606 unchanged lines |
modified crates/hub/src/db/flow.rs +32 −0
| … | 422 unchanged lines |
||
| 423 | 423 | .await?; |
|
| 424 | 424 | Ok(()) |
|
| 425 | 425 | } |
|
| 426 | + | ||
| 427 | + | /// A job's artifact, kept as a blob of the run's repository. |
|
| 428 | + | #[derive(Clone, Debug, FromRow)] |
|
| 429 | + | pub struct Artifact { |
|
| 430 | + | pub job_id: i64, |
|
| 431 | + | pub name: String, |
|
| 432 | + | pub path: String, |
|
| 433 | + | pub blob: String, |
|
| 434 | + | pub size: i64, |
|
| 435 | + | /// BLAKE3 of the bytes. |
|
| 436 | + | pub content: String, |
|
| 437 | + | pub created_ms: i64, |
|
| 438 | + | } |
|
| 439 | + | ||
| 440 | + | pub async fn add_artifact(pool: &Pool, a: &Artifact) -> sqlx::Result<()> { |
|
| 441 | + | sqlx::query("INSERT OR REPLACE INTO flow_artifacts (job_id, name, path, blob, size, content, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?)") |
|
| 442 | + | .bind(a.job_id) |
|
| 443 | + | .bind(&a.name) |
|
| 444 | + | .bind(&a.path) |
|
| 445 | + | .bind(&a.blob) |
|
| 446 | + | .bind(a.size) |
|
| 447 | + | .bind(&a.content) |
|
| 448 | + | .bind(a.created_ms) |
|
| 449 | + | .execute(pool) |
|
| 450 | + | .await?; |
|
| 451 | + | Ok(()) |
|
| 452 | + | } |
|
| 453 | + | ||
| 454 | + | /// Job `job_id`'s artifacts, by name. |
|
| 455 | + | pub async fn artifacts(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Artifact>> { |
|
| 456 | + | sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = ? ORDER BY name").bind(job_id).fetch_all(pool).await |
|
| 457 | + | } |
modified crates/hub/src/flow.rs +40 −0
| … | 348 unchanged lines |
||
| 349 | 349 | Ok(StatusCode::NO_CONTENT) |
|
| 350 | 350 | } |
|
| 351 | 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 | + | ||
| 352 | 387 | /// `POST /api/v1/flow/jobs/{id}/finish`. |
|
| 353 | 388 | pub async fn finish( |
|
| 354 | 389 | State(app): State<Shared>, |
|
| … | 10 unchanged lines |
||
| 365 | 400 | }; |
|
| 366 | 401 | let (receipt, receipt_hash) = match &f.receipt { |
|
| 367 | 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 | + | } |
|
| 368 | 408 | (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash())) |
|
| 369 | 409 | } |
|
| 370 | 410 | Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())), |
|
| … | 90 unchanged lines |
modified crates/hub/src/web/flow.rs +33 −1
| … | 227 unchanged lines |
||
| 228 | 228 | ||
| 229 | 229 | pub struct JobLog { |
|
| 230 | 230 | pub j: fdb::Job, |
|
| 231 | + | pub artifacts: Vec<fdb::Artifact>, |
|
| 231 | 232 | pub lines: Vec<fdb::LogLine>, |
|
| 232 | 233 | pub duration: String, |
|
| 233 | 234 | /// The last line shown; the live view asks for what follows. |
|
| … | 32 unchanged lines |
||
| 266 | 267 | for j in &jobs { |
|
| 267 | 268 | let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default(); |
|
| 268 | 269 | let last_seq = lines.last().map(|l| l.seq).unwrap_or(0); |
|
| 269 | - | logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), lines, last_seq }); |
|
| 270 | + | let artifacts = fdb::artifacts(&app.db, j.id).await.unwrap_or_default(); |
|
| 271 | + | logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq }); |
|
| 270 | 272 | } |
|
| 271 | 273 | let duration = match run.finished_ms { |
|
| 272 | 274 | Some(f) => crate::flow::human_ms(f - run.created_ms), |
|
| … | 253 unchanged lines |
||
| 526 | 528 | [ |
|
| 527 | 529 | (axum::http::header::CONTENT_TYPE, "application/oxy-receipt".to_string()), |
|
| 528 | 530 | (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}-{}.receipt\"", r.short(12))), |
|
| 531 | + | ], |
|
| 532 | + | bytes, |
|
| 533 | + | ) |
|
| 534 | + | .into_response()) |
|
| 535 | + | } |
|
| 536 | + | ||
| 537 | + | /// `GET /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}`: an artifact |
|
| 538 | + | /// of a run's job, read from the repository. |
|
| 539 | + | pub async fn artifact_file( |
|
| 540 | + | State(app): State<Shared>, |
|
| 541 | + | viewer: Viewer, |
|
| 542 | + | Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>, |
|
| 543 | + | ) -> Result<Response, Error> { |
|
| 544 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}"), &viewer); |
|
| 545 | + | let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 546 | + | let run = fdb::run(&app.db, row.id, id) |
|
| 547 | + | .await |
|
| 548 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 549 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 550 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 551 | + | let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) }; |
|
| 552 | + | let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 553 | + | let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) }; |
|
| 554 | + | let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) }; |
|
| 555 | + | let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?; |
|
| 556 | + | let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string(); |
|
| 557 | + | Ok(( |
|
| 558 | + | [ |
|
| 559 | + | (axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()), |
|
| 560 | + | (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file.replace('"', ""))), |
|
| 529 | 561 | ], |
|
| 530 | 562 | bytes, |
|
| 531 | 563 | ) |
|
| … | 2 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 150 unchanged lines |
||
| 151 | 151 | .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp)) |
|
| 152 | 152 | .route("/{owner}/{name}/flow/at/{r}", get(flow::at)) |
|
| 153 | 153 | .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file)) |
|
| 154 | + | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}", get(flow::artifact_file)) |
|
| 154 | 155 | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| 155 | 156 | .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create)) |
|
| 156 | 157 | .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show)) |
|
| … | 431 unchanged lines |
modified crates/hub/tests/flow.rs +59 −0
| … | 778 unchanged lines |
||
| 779 | 779 | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/change/{}", heads[0].to_hex()), None, None).await; |
|
| 780 | 780 | assert!(page.contains("<dt>Receipts</dt>") && page.contains(&format!("/andy/demo/receipts/{attest}")), "change page"); |
|
| 781 | 781 | } |
|
| 782 | + | ||
| 783 | + | async fn upload(app: &Shared, job: i64, name: &str, bytes: &[u8]) -> (StatusCode, String) { |
|
| 784 | + | let req = Request::post(format!("/api/v1/flow/jobs/{job}/artifacts/{name}")) |
|
| 785 | + | .header(header::AUTHORIZATION, "Bearer runner-secret") |
|
| 786 | + | .header(header::CONTENT_TYPE, "application/octet-stream") |
|
| 787 | + | .body(Body::from(bytes.to_vec())) |
|
| 788 | + | .unwrap(); |
|
| 789 | + | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 790 | + | let status = res.status(); |
|
| 791 | + | (status, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 792 | + | } |
|
| 793 | + | ||
| 794 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 795 | + | async fn artifacts_are_kept_named_by_the_receipt_and_served() { |
|
| 796 | + | let h = hub().await; |
|
| 797 | + | create(&h.app, "andy", "demo").await; |
|
| 798 | + | let url = format!("{}/andy/demo", h.base); |
|
| 799 | + | let work = tempfile::tempdir().unwrap(); |
|
| 800 | + | let c = client(&url, work.path()); |
|
| 801 | + | write( |
|
| 802 | + | work.path(), |
|
| 803 | + | ".flow/pipeline.kdl", |
|
| 804 | + | &format!( |
|
| 805 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n sandbox \"alpine\"\n exec \"make\"\n output {{\n artifact \"tool\" {{\n path \"/src/out/tool\"\n }}\n }}\n}}\n" |
|
| 806 | + | ), |
|
| 807 | + | ); |
|
| 808 | + | c.snapshot("test").unwrap(); |
|
| 809 | + | c.commit("Build a tool", &[]).unwrap(); |
|
| 810 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 811 | + | let run = runs_of(&h.app, "andy", "demo").await.remove(0); |
|
| 812 | + | let a = claim(&h.app).await.unwrap(); |
|
| 813 | + | assert_eq!(a["job"]["outputs"], serde_json::json!([{ "name": "tool", "path": "/src/out/tool" }]), "the runner is told"); |
|
| 814 | + | let id = a["job_id"].as_i64().unwrap(); |
|
| 815 | + | ||
| 816 | + | let body: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect(); |
|
| 817 | + | let (st, text) = upload(&h.app, id, "other", &body).await; |
|
| 818 | + | assert_eq!(st, StatusCode::BAD_REQUEST, "an undeclared artifact: {text}"); |
|
| 819 | + | let (st, text) = upload(&h.app, id, "tool", &body).await; |
|
| 820 | + | assert_eq!(st, StatusCode::OK, "{text}"); |
|
| 821 | + | let blob = serde_json::from_str::<serde_json::Value>(&text).unwrap()["blob"].as_str().unwrap().to_string(); |
|
| 822 | + | assert_eq!(blob, oxy_core::repo::Repo::blob_ref(&body).to_hex(), "the same blob the runner computes"); |
|
| 823 | + | ||
| 824 | + | // The receipt must name exactly the uploaded artifacts. |
|
| 825 | + | let mut r = receipt("build", a["pipeline_hash"].as_str().unwrap(), 0); |
|
| 826 | + | assert_eq!( |
|
| 827 | + | finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": r.clone() })).await, |
|
| 828 | + | StatusCode::BAD_REQUEST |
|
| 829 | + | ); |
|
| 830 | + | r["outputs"] = serde_json::json!([blob]); |
|
| 831 | + | assert_eq!(finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": r })).await, StatusCode::NO_CONTENT); |
|
| 832 | + | ||
| 833 | + | // The run page lists it; anyone who can read the repository downloads it. |
|
| 834 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await; |
|
| 835 | + | let link = format!("/andy/demo/flow/{}/artifacts/build/tool", run.number); |
|
| 836 | + | assert!(page.contains(&link), "run page lists the artifact"); |
|
| 837 | + | let (st, got) = get_bytes(&h.app, &link).await; |
|
| 838 | + | assert_eq!((st, got == body), (StatusCode::OK, true)); |
|
| 839 | + | assert_eq!(get_bytes(&h.app, &format!("/andy/demo/flow/{}/artifacts/build/none", run.number)).await.0, StatusCode::NOT_FOUND); |
|
| 840 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +6 −0
| … | 431 unchanged lines |
||
| 432 | 432 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 433 | 433 | ||
| 434 | 434 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 435 | + | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| 436 | + | - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB). |
|
| 437 | + | - The hub refuses names the job does not declare. It keeps each artifact as a blob of the run's repository, records its path, size and BLAKE3 in `flow_artifacts`, and answers with the blob's ObjectRef. |
|
| 438 | + | - `finish` refuses a receipt whose `outputs` are not exactly the uploaded blobs, in name order. |
|
| 439 | + | - The run page lists each job's artifacts, and `/{owner}/{name}/flow/{n}/artifacts/{job}/{name}` downloads one. |
|
| 440 | + | - A kept receipt keeps its artifacts through garbage collection (Oxygen spec §14.6). |
|
| 435 | 441 | - **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories. |
|
| 436 | 442 | - **Checks.** Each job is also the check `flow/<job>` on the run's state (§13.1), so proposals, required checks and the editor's status bar see it. |
|
| 437 | 443 | - **Pages.** A **Flow** tab lists runs (status, bookmark, state, pusher, when, duration). A run page draws the job graph as SVG, with columns by dependency depth and status by colour and glyph, and each job's log below (folded, the failed ones open); it refreshes while the run is live. Readers of the repository can see both. |
|
| … | 156 unchanged lines |
modified templates/flow_run.html +1 −1
| … | 27 unchanged lines |
||
| 28 | 28 | </section> |
|
| 29 | 29 | {% for l in logs %} |
|
| 30 | 30 | <details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}> |
|
| 31 | - | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}</summary> |
|
| 31 | + | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {% for a in l.artifacts %}<a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}" title="{{ a.path }}, blob {{ a.blob }}">{{ a.name }}</a> ({{ a.size|bytes }}){% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</summary> |
|
| 32 | 32 | {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %} |
|
| 33 | 33 | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| 34 | 34 | {% endfor %}</pre> |
|
| … | 6 unchanged lines |