OXYGENHUB

Checkpoints, so a handed-back job resumes (refs andymccall/oxygenflow#77) e4203e141e90

POST and GET /api/v1/flow/jobs/{id}/checkpoint: a runner handing a job
back keeps the directories it declares with checkpoint, as a blob of the
job's repository, and the next attempt's claim names it for its runner
to restore. Kept beside the job's artifacts as .checkpoint, which no
pipeline can declare and no receipt names, so it is left out of them.
Pins OxygenFlow's main.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b265f3d32881
When
· 2026-10-25 22:24 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
e4203e141e90bb28409a102a527e3af1b341c1d61a1091793b724a58fbbd1ad0
Depends on
10708ab8f54d 16ee5d19d220 380996db84e5 3b65065298b7 42fa95540468 4597750166e7 5d90e0e045e2 946fe7a09faa a36bab6b9ffc a3ba7cc10673 a3c8288b0141 ab4df6aae179 b5668108487f d23464c0ac60
Depended on by
8501fd5815b9
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
13 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
5 unchanged lines
6 6
7 7
## 0.9.4 (2026-10-25)
8 8
9 +
- **Checkpoints, so a handed-back job resumes (oxygenflow#77):** `POST /api/v1/flow/jobs/{id}/checkpoint` keeps the directories a job declares with `checkpoint "/src/…"` as its runner hands it back; the next attempt's claim names the blob (`checkpoint`) and its runner fetches it (`GET`) and restores it before running. A checkpoint is kept beside the job's artifacts but is none of them (no receipt names it). Pins OxygenFlow's main.
9 10
- **Boards keep the last fortnight of finished work (#293):** by default every column archives its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, hourly, so a Done column stops growing however long a project runs; open work and notes stay. A column can instead archive every card after N days, or never ("Archive automatically" in its menu). Archived cards stay on the board's archive view, searchable and restorable. On the next hourly pass, boards with years of Done cards (Super Mario Land 2's 327) shrink to their last two weeks.
10 11
- **An issue's references: no line through the chips, each change once (#318):** an opened "referenced by N changes" box drew the proposal timeline's vertical rule through its first column (the events shared its class); and a change was recorded again for every bookmark it was pushed on, and for every mention in its message, so one change filled several chips. Now one reference per change per issue or proposal, and the page shows each change once, including those recorded before.
11 12
- **Claims say how long a job is expected to take (oxygenflow#76):** `expected_ms`, the median of the job's last five passing runs in the repository (reused passes left out), so a runner leaving with notice finishes a job that fits it and hands back the rest. Pins OxygenFlow's main.
…
324 unchanged lines
modified crates/hub/src/api/mod.rs +6 −0
…
72 unchanged lines
73 73
        .route("/flow/jobs/{id}/decline", post(crate::flow::decline))
74 74
        .route("/flow/jobs/{id}/release", post(crate::flow::release))
75 75
        .route(
76 +
            "/flow/jobs/{id}/checkpoint",
77 +
            post(crate::flow::save_checkpoint)
78 +
                .get(crate::flow::checkpoint)
79 +
                .layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))),
80 +
        )
81 +
        .route(
76 82
            "/flow/jobs/{id}/artifacts/{name}",
77 83
            post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))),
78 84
        )
…
2986 unchanged lines
modified crates/hub/src/db/flow.rs +13 −2
…
1384 unchanged lines
1385 1385
    .await
1386 1386
}
1387 1387
1388 -
/// Job `job_id`'s artifacts, by name.
1388 +
/// Job `job_id`'s artifacts, by name; not its [`CHECKPOINT`], which no
1389 +
/// receipt names.
1389 1390
pub async fn artifacts(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Artifact>> {
1390 -
    sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await
1391 +
    sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 AND name NOT LIKE '.%' ORDER BY name").bind(job_id).fetch_all(pool).await
1391 1392
}
1392 1393
1394 +
/// The name a job's checkpoint is kept under (oxygenflow#77), beside its
1395 +
/// artifacts: no pipeline can declare it.
1396 +
pub const CHECKPOINT: &str = ".checkpoint";
1397 +
1398 +
/// The checkpoint an attempt of job `job_id` saved as its runner handed it
1399 +
/// back (oxygenflow#77), if one did.
1400 +
pub async fn checkpoint(pool: &Pool, job_id: i64) -> sqlx::Result<Option<Artifact>> {
1401 +
    sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 AND name = $2").bind(job_id).bind(CHECKPOINT).fetch_optional(pool).await
1402 +
}
1403 +
1393 1404
/// An artifact with the run and job that made it, for a repository's list
1394 1405
/// of every run's outputs (oxygenhub#254).
1395 1406
#[derive(Clone, Debug, FromRow)]
…
115 unchanged lines
modified crates/hub/src/flow.rs +70 −0
…
1166 unchanged lines
1167 1167
        // For a runner that must leave: finish it, or hand it back
1168 1168
        // (oxygenflow#76).
1169 1169
        expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64),
1170 +
        // What an attempt handed back saved, to resume from (oxygenflow#77).
1171 +
        checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None },
1170 1172
    };
1171 1173
    let mut claim = claim;
1172 1174
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
…
206 unchanged lines
1379 1381
    Ok(n)
1380 1382
}
1381 1383
1384 +
/// `POST /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): a runner
1385 +
/// handing a job back keeps its checkpoint directories, packed; kept as a
1386 +
/// blob of the job's repository, for the next attempt's claim to name.
1387 +
pub async fn save_checkpoint(
1388 +
    State(app): State<Shared>,
1389 +
    headers: HeaderMap,
1390 +
    Path(id): Path<i64>,
1391 +
    body: axum::body::Bytes,
1392 +
) -> Result<Response, ApiError> {
1393 +
    let runner = runner_auth(&app, &headers).await?;
1394 +
    let job = running_job(&app, id).await?;
1395 +
    if runner.source == "registered" && job.runner != runner.name {
1396 +
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1397 +
    }
1398 +
    let spec: flow_core::Job =
1399 +
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
1400 +
    if spec.checkpoints.is_empty() {
1401 +
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name)));
1402 +
    }
1403 +
    let repo = job_repo(&app, &job).await?;
1404 +
    let content = blake3::hash(&body).to_hex().to_string();
1405 +
    let size = body.len() as i64;
1406 +
    let blob = app
1407 +
        .repos
1408 +
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
1409 +
        .await
1410 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?;
1411 +
    let a = fdb::Artifact {
1412 +
        job_id: job.id,
1413 +
        name: fdb::CHECKPOINT.into(),
1414 +
        path: spec.checkpoints.join(" "),
1415 +
        blob: blob.to_hex(),
1416 +
        size,
1417 +
        content,
1418 +
        created_ms: db::now_ms(),
1419 +
    };
1420 +
    fdb::add_artifact(&app.db, &a).await?;
1421 +
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1422 +
}
1423 +
1424 +
/// `GET /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): the checkpoint
1425 +
/// a handed-back attempt saved, for the attempt now running it.
1426 +
pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> {
1427 +
    let runner = runner_auth(&app, &headers).await?;
1428 +
    let job = running_job(&app, id).await?;
1429 +
    if runner.source == "registered" && job.runner != runner.name {
1430 +
        return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into()));
1431 +
    }
1432 +
    let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?;
1433 +
    let repo = job_repo(&app, &job).await?;
1434 +
    let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob)
1435 +
        .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?;
1436 +
    let bytes = app
1437 +
        .repos
1438 +
        .read(&repo, move |r, _| Ok(r.read_blob(&blob)?))
1439 +
        .await
1440 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?;
1441 +
    Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1442 +
}
1443 +
1444 +
/// The repository job `job` runs for.
1445 +
async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> {
1446 +
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1447 +
    let row =
1448 +
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
1449 +
    RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))
1450 +
}
1451 +
1382 1452
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1383 1453
/// runner hands back a job it holds, as it leaves (off the allowed network,
1384 1454
/// a restart, a spot reclaim). Not a decline: the runner is not held from
…
1551 unchanged lines
modified crates/hub/tests/flow.rs +16 −2
…
2631 unchanged lines
2632 2632
    let url = format!("{}/andy/drains", h.base);
2633 2633
    let work = tempfile::tempdir().unwrap();
2634 2634
    let c = client(&url, work.path());
2635 -
    let bulk = "job \"bulk\" {\n    sandbox \"alpine\"\n    priority \"low\"\n    exec \"true\"\n}\n";
2635 +
    let bulk = "job \"bulk\" {\n    sandbox \"alpine\"\n    priority \"low\"\n    checkpoint \"/src/state\"\n    exec \"true\"\n}\n";
2636 2636
    write(work.path(), ".flow/pipeline.kdl", &pipeline(bulk));
2637 2637
    write(work.path(), "test.sh", "echo ok\n");
2638 2638
    c.commit("Add a pipeline", &[]).unwrap();
…
26 unchanged lines
2665 2665
    let line = serde_json::json!({ "records": [{ "t": 1200, "stream": "stdout", "text": "step 1 of 30" }] }).to_string();
2666 2666
    call(&h.app, "POST", &format!("/api/v1/flow/jobs/{low}/log"), Some("runner-three"), Some(line)).await;
2667 2667
    assert_eq!(finish(&h.app, build, serde_json::json!({ "exit_code": 0, "duration_ms": 700 })).await, StatusCode::NO_CONTENT);
2668 +
    // It keeps its checkpoint first (oxygenflow#77), a job's own only.
2669 +
    let (st, body) = call(&h.app, "POST", &format!("/api/v1/flow/jobs/{lint}/checkpoint"), Some("runner-two"), Some("x".into())).await;
2670 +
    assert_eq!(st, StatusCode::BAD_REQUEST, "lint declares no checkpoint: {body}");
2671 +
    let (st, body) =
2672 +
        call(&h.app, "POST", &format!("/api/v1/flow/jobs/{low}/checkpoint"), Some("runner-three"), Some("step 32".into())).await;
2673 +
    assert_eq!(st, StatusCode::OK, "{body}");
2674 +
    let saved = serde_json::from_str::<serde_json::Value>(&body).unwrap()["blob"].as_str().unwrap().to_string();
2668 2675
    assert_eq!(release("runner-two", low, "off Wi-Fi").await, StatusCode::CONFLICT, "only the runner that holds it");
2669 2676
    assert_eq!(release("runner-three", low, "off Wi-Fi").await, StatusCode::NO_CONTENT);
2670 2677
    let j = db::flow::job(&h.app.db, low).await.unwrap().unwrap();
…
3 unchanged lines
2674 2681
    assert!(db::flow::declines_of_job(&h.app.db, low).await.unwrap().is_empty(), "not a decline");
2675 2682
    // `test` became ready when `build` passed, and is of higher priority:
2676 2683
    // the handed-back job still goes first, to any runner, its own too.
2677 -
    assert_eq!(claim_as("runner-three", "phone").await, Some(low));
2684 +
    let body = serde_json::json!({ "runner": "phone", "runtimes": ["microvm"] }).to_string();
2685 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-three"), Some(body)).await;
2686 +
    assert_eq!(st, StatusCode::OK, "{body}");
2687 +
    let again: serde_json::Value = serde_json::from_str(&body).unwrap();
2688 +
    assert_eq!((again["job_id"].as_i64(), again["checkpoint"].as_str()), (Some(low), Some(saved.as_str())), "it resumes: {again}");
2689 +
    let (st, bytes) = call(&h.app, "GET", &format!("/api/v1/flow/jobs/{low}/checkpoint"), Some("runner-three"), None).await;
2690 +
    assert_eq!((st, bytes.as_str()), (StatusCode::OK, "step 32"));
2691 +
    assert!(db::flow::artifacts(&h.app.db, low).await.unwrap().is_empty(), "a checkpoint is no artifact of the job");
2678 2692
    assert_eq!(claim_as("runner-secret", "earth-1").await.map(|id| id != low), Some(true), "then new work");
2679 2693
2680 2694
    // The phone goes quiet: once its lease lapses the job is handed back.
…
3869 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.