OXYGENHUB

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