OXYGENHUB

Placement ranks runners and says why a job ran where it did 501d8b268dd4

When a runner claims, a job another free runner would do better (it
holds the job's image while the claimant would fetch it, or it has a
higher speed class), that polled in the last 10 s, is held from the
claimant for up to 15 s; after that any runner that fits takes it. The
fit check behind the waiting reasons is now one function both use. Each
job records why it ran where it did (migration 0055): 'holds the image,
idle, speed 100 (class 3)', with 'after N s held for a better runner'
when it was; shown on the run page, in the API and by oh run view.

refs andymccall/oxygenflow#42
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 22:49 UTC
Signature
✔ signed by Andy McCall
Hash
501d8b268dd41fb957743014aff920aa0e26226d075e1d2fff07ebf54a46f65e
Depends on
0b203bbf7cc3 42fa95540468 44a3d2ebaddd 4d2e44d79b03 5c6e92fd8d71 67040b158ae2 74be97962ed8 7f5f6c056d8f 90326c9601f9 92a797b20912 93be9ce4653e 941abdd6bb4c b6b7e4cf6b24 c4c87f481d05 cc19697f8871 d23464c0ac60
Depended on by
1ec29691b67a b1860d6090ec 0468394734e9 951aed50c2f5 d7050703d2f4 56b8dbe66974 5588bb3631d2 858eca0d03c4 58cbf42447bc 3b1bc27fe805 5d90e0e045e2 417184c73f4e d9b3b84605a4 e8fa53694f68 c2be148cfe83 4fa0da126b70 c95446a17fdd 1c8276b90314 20b8fe85888b
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
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
24 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/hub/migrations/0055_job_placed.sql +3 −0
1 +
-- Why a job ran where it did (oxygenflow#42, Jetstream §7): "holds the
2 +
-- image, idle, speed 100 (class 3)".
3 +
ALTER TABLE flow_jobs ADD COLUMN placed TEXT NOT NULL DEFAULT '';
modified crates/hub-api/src/lib.rs +4 −0
…
554 unchanged lines
555 555
    /// empty when it only waits its turn.
556 556
    #[serde(default, skip_serializing_if = "String::is_empty")]
557 557
    pub waiting: String,
558 +
    /// Why it ran on its runner (oxygenflow#42): "holds the image, idle,
559 +
    /// speed 100 (class 3)".
560 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
561 +
    pub placed: String,
558 562
}
559 563
560 564
/// A line of a job's log.
…
694 unchanged lines
modified crates/hub/src/api/mod.rs +1 −0
…
1867 unchanged lines
1868 1868
                finished_ms: j.finished_ms,
1869 1869
                log_lines: j.log_count,
1870 1870
                waiting: j.waiting,
1871 +
                placed: j.placed,
1871 1872
            })
1872 1873
            .collect(),
1873 1874
    }
…
532 unchanged lines
modified crates/hub/src/db/flow.rs +27 −0
…
94 unchanged lines
95 95
    pub peak_memory_mib: Option<i64>,
96 96
    /// The free disk it needs, learned from past runs of the same job.
97 97
    pub need_disk_mib: Option<i64>,
98 +
    /// Why it ran on its runner (oxygenflow#42).
99 +
    pub placed: String,
98 100
}
99 101
100 102
impl Job {
…
45 unchanged lines
146 148
    Ok(peak.map(|p| p + p / 4 + 64))
147 149
}
148 150
151 +
/// Record why job `job_id` ran where it did.
152 +
pub async fn set_placed(pool: &Pool, job_id: i64, placed: &str) -> sqlx::Result<()> {
153 +
    sqlx::query("UPDATE flow_jobs SET placed = ? WHERE id = ?").bind(placed).bind(job_id).execute(pool).await?;
154 +
    Ok(())
155 +
}
156 +
149 157
/// Keep what a job took, as its runner reported.
150 158
pub async fn set_peaks(pool: &Pool, job_id: i64, disk_mib: Option<u64>, memory_mib: Option<u64>) -> sqlx::Result<()> {
151 159
    sqlx::query("UPDATE flow_jobs SET peak_disk_mib = ?, peak_memory_mib = ? WHERE id = ?")
…
296 unchanged lines
448 456
    scope: &super::runners::Scope,
449 457
    labels: &[String],
450 458
) -> sqlx::Result<Option<Job>> {
459 +
    claim_ranked(pool, runtimes, runner, token_hash, room, scope, labels, &|_| false).await
460 +
}
461 +
462 +
/// [`claim_within`], passing over a job `elsewhere` says a better runner
463 +
/// should take (oxygenflow#42).
464 +
#[allow(clippy::too_many_arguments)]
465 +
pub async fn claim_ranked(
466 +
    pool: &Pool,
467 +
    runtimes: &[String],
468 +
    runner: &str,
469 +
    token_hash: &str,
470 +
    room: Room,
471 +
    scope: &super::runners::Scope,
472 +
    labels: &[String],
473 +
    elsewhere: &(dyn Fn(&Job) -> bool + Sync),
474 +
) -> sqlx::Result<Option<Job>> {
451 475
    let mut tx = super::begin_write(pool).await?;
452 476
    let queued: Vec<Job> = sqlx::query_as(
453 477
        "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \
…
35 unchanged lines
489 513
            if passed != deps.len() as i64 {
490 514
                continue;
491 515
            }
516 +
        }
517 +
        if elsewhere(&j) {
518 +
            continue;
492 519
        }
493 520
        let now = now_ms();
494 521
        let done = sqlx::query(
…
446 unchanged lines
modified crates/hub/src/flow.rs +148 −53
…
515 unchanged lines
516 516
            labels.push(l.to_string());
517 517
        }
518 518
    }
519 +
    // Ranking (oxygenflow#42): a job another free runner would do better
520 +
    // (it holds the image, or is faster) is held for it briefly.
521 +
    let runners = db::runners::list(&app.db).await?;
522 +
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
523 +
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
524 +
    let owners: std::collections::HashMap<i64, (String, i64)> =
525 +
        fdb::queue(&app.db).await?.into_iter().map(|q| (q.job_id, (q.owner, q.repo_id))).collect();
526 +
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
527 +
    let now = db::now_ms();
528 +
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
529 +
    let better = |j: &fdb::Job| -> bool {
530 +
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
531 +
        let rootfs = rootfs_of(j);
532 +
        let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner());
533 +
        let holds = holds.get_or_insert_with(Default::default);
534 +
        holds.retain(|_, t| now - *t < 600_000);
535 +
        if holds.get(&j.id).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
536 +
            return false;
537 +
        }
538 +
        let mine = rank(&me_now, &rootfs);
539 +
        let other = runners.iter().any(|r| {
540 +
            r.id != me.id
541 +
                && r.state == "active"
542 +
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
543 +
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
544 +
                && rank(r, &rootfs) > mine
545 +
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
546 +
        });
547 +
        if other {
548 +
            holds.entry(j.id).or_insert(now);
549 +
        }
550 +
        other
551 +
    };
519 552
    let Some(job) =
520 -
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
553 +
        fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await?
521 554
    else {
522 555
        return Ok(StatusCode::NO_CONTENT.into_response());
523 556
    };
557 +
    // Why here (oxygenflow#42).
558 +
    let waited = HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&job.id)).map(|t| now - t);
559 +
    let rootfs = rootfs_of(&job);
560 +
    let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) {
561 +
        "holds the image".to_string()
562 +
    } else {
563 +
        "fetches the image from the hub".to_string()
564 +
    }];
565 +
    match running.get(&me.id).copied().unwrap_or(0) {
566 +
        0 => why.push("idle".into()),
567 +
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
568 +
    }
569 +
    if let Some(sp) = me_now.speed {
570 +
        why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0)));
571 +
    }
572 +
    if let Some(w) = waited {
573 +
        why.push(format!("after {} s held for a better runner", w / 1000));
574 +
    }
575 +
    fdb::set_placed(&app.db, job.id, &why.join(", ")).await?;
524 576
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
525 577
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
526 578
    let row =
…
153 unchanged lines
680 732
        }
681 733
    }
682 734
    Ok(StatusCode::NO_CONTENT)
735 +
}
736 +
737 +
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
738 +
/// now (spec §11.4), from what each runner offered at its last claim:
739 +
/// empty when one could (it waits its turn or a free slot), else what
740 +
/// each one lacks. `declines` are the job's.
741 +
/// What placement knows of the fleet: images' architectures, the images
742 +
/// the hub holds, and the time.
743 +
pub struct Fleet<'a> {
744 +
    pub image_archs: &'a std::collections::HashMap<String, String>,
745 +
    pub hub_images: &'a std::collections::HashSet<String>,
746 +
    pub now: i64,
747 +
}
748 +
749 +
/// Why runner `r` cannot take `job` of a repository of `owner` (id
750 +
/// `repo_id`), from what it offered at its last claim (spec §11.4); `None`
751 +
/// when it can. `declines` are the job's.
752 +
pub fn runner_lacks(
753 +
    job: &fdb::Job,
754 +
    owner: &str,
755 +
    repo_id: i64,
756 +
    r: &db::runners::Runner,
757 +
    fleet: &Fleet,
758 +
    declines: &[fdb::DeclineRow],
759 +
) -> Option<String> {
760 +
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
761 +
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
762 +
    let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned());
763 +
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
764 +
    if r.state == "draining" {
765 +
        return Some("is draining".into());
766 +
    }
767 +
    let scope = r.scope();
768 +
    if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
769 +
        return Some("serves other repositories".into());
770 +
    }
771 +
    let tiers = r.tier_list();
772 +
    if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
773 +
        return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
774 +
    }
775 +
    if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
776 +
        return Some(format!("is {}; the job needs {w}", r.arch));
777 +
    }
778 +
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
779 +
    if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
780 +
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
781 +
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
782 +
    }
783 +
    let labels = r.label_list();
784 +
    if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) {
785 +
        return Some(format!("lacks label {l}"));
786 +
    }
787 +
    if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
788 +
        return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
789 +
    }
790 +
    if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
791 +
        return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
792 +
    }
793 +
    // The disk it learned it needs (oxygenflow#41).
794 +
    if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
795 +
        if free < n {
796 +
            return Some(format!(
797 +
                "has {} free; the job needs ~{} (its recent runs took up to {})",
798 +
                mib(free),
799 +
                mib(n),
800 +
                mib((n - 64) * 4 / 5)
801 +
            ));
802 +
        }
803 +
    }
804 +
    if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) {
805 +
        return Some(format!("declined it: {}", d.reason));
806 +
    }
807 +
    None
683 808
}
684 809
685 810
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
…
11 unchanged lines
697 822
    now: i64,
698 823
    hub_images: &std::collections::HashSet<String>,
699 824
) -> String {
700 -
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
701 -
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
702 -
    let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned());
703 -
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
704 -
    let needs = job.needs();
705 -
    let lacks = |r: &db::runners::Runner| -> Option<String> {
706 -
        if r.state == "draining" {
707 -
            return Some("is draining".into());
708 -
        }
709 -
        let scope = r.scope();
710 -
        if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
711 -
            return Some("serves other repositories".into());
712 -
        }
713 -
        let tiers = r.tier_list();
714 -
        if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
715 -
            return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
716 -
        }
717 -
        if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
718 -
            return Some(format!("is {}; the job needs {w}", r.arch));
719 -
        }
720 -
        let fetches = r.fetches_images && hub_images.contains(rootfs);
721 -
        if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
722 -
            let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
723 -
            return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
724 -
        }
725 -
        let labels = r.label_list();
726 -
        if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) {
727 -
            return Some(format!("lacks label {l}"));
728 -
        }
729 -
        if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
730 -
            return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
731 -
        }
732 -
        if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
733 -
            return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
734 -
        }
735 -
        // The disk it learned it needs (oxygenflow#41).
736 -
        if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) {
737 -
            if free < n {
738 -
                return Some(format!(
739 -
                    "has {} free; the job needs ~{} (its recent runs took up to {})",
740 -
                    mib(free),
741 -
                    mib(n),
742 -
                    mib((n - 64) * 4 / 5)
743 -
                ));
744 -
            }
745 -
        }
746 -
        if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) {
747 -
            return Some(format!("declined it: {}", d.reason));
748 -
        }
749 -
        None
750 -
    };
825 +
    let fleet = Fleet { image_archs, hub_images, now };
751 826
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
752 827
    if online.is_empty() {
753 828
        return "no runner is online".into();
754 829
    }
755 830
    let mut why = Vec::new();
756 831
    for r in &online {
757 -
        match lacks(r) {
832 +
        match runner_lacks(job, owner, repo_id, r, &fleet, declines) {
758 833
            None => return String::new(),
759 834
            Some(w) => why.push(format!("{} {w}", r.name)),
760 835
        }
…
5 unchanged lines
766 841
        text.push_str(&format!("; and {more} more"));
767 842
    }
768 843
    text
844 +
}
845 +
846 +
/// How long a job is held for a better runner before any runner that fits
847 +
/// takes it (oxygenflow#42).
848 +
const BETTER_HOLD_MS: i64 = 15_000;
849 +
/// A runner counts as there to take a job when it asked this recently.
850 +
const POLLING_MS: i64 = 10_000;
851 +
852 +
/// When each job was first passed over for a better runner.
853 +
static HELD: std::sync::Mutex<Option<std::collections::HashMap<i64, i64>>> = std::sync::Mutex::new(None);
854 +
855 +
/// How good runner `r` is for a job on image `rootfs`: holding the image
856 +
/// counts most, then the speed class.
857 +
fn rank(r: &db::runners::Runner, rootfs: &str) -> i64 {
858 +
    let holds = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs));
859 +
    (holds as i64) * 10 + r.speed_class.unwrap_or(0)
860 +
}
861 +
862 +
fn rootfs_of(job: &fdb::Job) -> String {
863 +
    serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default()
769 864
}
770 865
771 866
/// A size in MiB as people say it: 9.4 GiB, 740 MiB.
…
988 unchanged lines
modified crates/hub/tests/flow.rs +50 −0
…
2786 unchanged lines
2787 2787
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(claim_with(20_000))).await;
2788 2788
    assert_eq!(st, StatusCode::OK, "a roomier runner takes it: {body}");
2789 2789
}
2790 +
2791 +
/// oxygenflow#42: a job another free runner would do better (it holds the
2792 +
/// image) is held from a runner that would fetch it, the better runner
2793 +
/// takes it, and the job says why it ran there.
2794 +
#[tokio::test(flavor = "multi_thread")]
2795 +
async fn a_job_waits_briefly_for_a_runner_that_holds_its_image() {
2796 +
    let h = hub().await;
2797 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2798 +
    // A second runner, with a token of its own.
2799 +
    db::runners::configured(&h.app.db, &blake3::hash(b"other-secret").to_hex(), "earth-9").await.unwrap();
2800 +
    let ask = |images: &str| {
2801 +
        format!(
2802 +
            r#"{{"runner":"r","runtimes":["microvm"],"images":[{images}],"fetches_images":true,"facts":{{"speed":100,"speed_class":3,"free_disk_mib":50000}}}}"#
2803 +
        )
2804 +
    };
2805 +
    let holder = ask(&format!("\"{ROOTFS}\""));
2806 +
    // earth-9 holds the image and is polling; nothing is queued yet.
2807 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder.clone())).await;
2808 +
    assert_eq!(st, StatusCode::NO_CONTENT);
2809 +
    // The hub holds the image too, so test-1 could fetch it.
2810 +
    sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, 1, 'x86_64', 1, 'andy', 0)")
2811 +
        .bind(ROOTFS)
2812 +
        .execute(&h.app.db)
2813 +
        .await
2814 +
        .unwrap();
2815 +
2816 +
    create(&h.app, "andy", "demo").await;
2817 +
    let url = format!("{}/andy/demo", h.base);
2818 +
    let work = tempfile::tempdir().unwrap();
2819 +
    let c = client(&url, work.path());
2820 +
    write(
2821 +
        work.path(),
2822 +
        ".flow/pipeline.kdl",
2823 +
        &format!("schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n    sandbox \"alpine\"\n    exec \"make\"\n}}\n"),
2824 +
    );
2825 +
    c.commit("Build", &[]).unwrap();
2826 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2827 +
    runs_of(&h.app, "andy", "demo").await;
2828 +
2829 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(""))).await;
2830 +
    assert_eq!(st, StatusCode::NO_CONTENT, "held for the runner that holds the image");
2831 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder)).await;
2832 +
    assert_eq!(st, StatusCode::OK, "{body}");
2833 +
    let id = serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap();
2834 +
    let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed;
2835 +
    assert!(placed.starts_with("holds the image, idle, speed 100 (class 3)"), "{placed}");
2836 +
    assert!(placed.contains("held for a better runner"), "{placed}");
2837 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", Some("site"), None).await;
2838 +
    assert!(body.contains("\"placed\":\"holds the image"), "{body}");
2839 +
}
modified crates/oh/src/main.rs +4 −1
…
1030 unchanged lines
1031 1031
        };
1032 1032
        let on = if j.runner.is_empty() { String::new() } else { format!(" on {}", j.runner) };
1033 1033
        println!("  {} {}{}", ui::status(&j.status), j.name, ui::dim(&format!("{dur}{on}")));
1034 -
        // Why a queued job no runner can take waits (oxygenhub#28).
1034 +
        // Why a queued job no runner can take waits (oxygenhub#28), and
1035 +
        // why a job ran where it did (oxygenflow#42).
1035 1036
        if j.status == "queued" && !j.waiting.is_empty() {
1036 1037
            println!("    {}", ui::warn(&format!("waiting: {}", j.waiting)));
1038 +
        } else if !j.placed.is_empty() {
1039 +
            println!("    {}", ui::dim(&format!("placed: {}", j.placed)));
1037 1040
        }
1038 1041
    }
1039 1042
    if !r.error.is_empty() {
…
503 unchanged lines
modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - 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`.
516 516
517 517
  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`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines
modified templates/flow_run.html +2 −1
…
31 unchanged lines
32 32
</section>
33 33
{% for l in logs %}
34 34
<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" || (l.j.status == "queued" && !l.j.waiting.is_empty()) %} open{% endif %}>
35 -
  <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">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary>
35 +
  <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 <span{% if !l.j.placed.is_empty() %} title="Placed there: {{ l.j.placed }}"{% endif %}>{{ l.j.runner }}</span>{% 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">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary>
36 36
  {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %}
37 37
  {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if l.j.status != "passed" %} <span class="warn" title="Kept with when &quot;always&quot;: the job did not pass, so its receipt does not name this file">· from a job that did not pass, not in its receipt</span>{% endif %}{% if !releases.is_empty() && l.j.status == "passed" %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
38 38
  {% if let Some(t) = l.tests %}<div class="flow-tests" id="tests-{{ l.j.id }}">
…
6 unchanged lines
45 45
  </div>{% endif %}
46 46
  <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>
47 47
{% endfor %}</pre>
48 +
  {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %}
48 49
  {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
49 50
  {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %}
50 51
</details>
…
3 unchanged lines