Diffusion: the proposal and its trigger a7e6e8e6094c
When a push moves an upstream's default bookmark (for consumers following every move) or a run of it passes (for those following green), each consumer gets one proposal from its diffusion/<path> bookmark into its target, opened by the hub's diffusion account, labelled dependencies, its body the upstream changes the pin lacks; later bumps update the same proposal. Diffusion never lands anything. refs #148
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 01:17 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a7e6e8e6094c0a836ddde37499e7b44d64db9db4a3404d871eaa92682aea12c6- Depends on
- 070a7b64b368 1d634f698ecd 42fa95540468 59338826539c 93be9ce4653e b5668108487f cc19697f8871 d23464c0ac60 e71583501b1d f677d6270298
- Depended on by
- 4dfa4599b3f7 06b756358f7e 91c306aa4706 b1860d6090ec a9088323c390 aeed63c00ad7 42cd46c9b09e 217814c239f0 351cf57e5608 046e3352cdfd 7c572789349b 84f09ad45851 5d66f27cebe2 0468394734e9 c4bffee5b0d4 951aed50c2f5 5caa65fd51e7 668b2ad7495c 15d3f6f7871b 7a91f1fd71b0 d9d0d314fdcb 4b49c41599f3 3e868a1f94e1 d7e2c2754807 34388e612963 262ec9c51a5c 5a9a6e9a15bc 27babfa16580 dd0788b756f2 d061ac87618c 8eae2f0e255c 9bbca80f777a faf5246c900e 1000b40a2629 d7050703d2f4 8501fd5815b9 c2efc0b4674d 5f3fa151688e d2263e299da7 17dc8de4fca8 a3ba7cc10673 946fe7a09faa 8bb7baeb4557 3b65065298b7 fbd7c8551932 c43e4f631d99 acb8d109e981 56b8dbe66974 a36c79fd9493 234921ea0834 ab4df6aae179 09ffa7b600a9 73c01b3448ee 5d818ec7d5fc 03bfb945f5c1 de4467cb1eca 5714347a6233 b7d2b0b30b4a f4e138301198 439ecd8708a1 6a306cd03fc1 69e4f27c5633 ecac6e8d9b41 657e7d5c9da9 4f8404093d30 456f4197ade3 794ff94f194a ba0a55bb5b3c a66c05a6a881 8ad8e656a3a6 8ac2aa4ca6d1 ca0c443c30cf c419455a82dd ab2d8b40f9f4 adbc38e7ea09 9e8cbfc3ad00 dd327d76062d 356183b71fab ec84907043c2 5588bb3631d2 9041db4e4d92 3b3a51afde59 737de0fac722 b349e15a3eab 8597fb5ca16c 367f34b8e590 8bf3010760d7 858eca0d03c4 787632570bf9 a549ddd5941f 4c77a1c8aad8 d366306dae7d a0a1601eb5a5 166f6e9bf4f1 c7067c7bda56 ea55fb62f919 cb7b3cd45dc0 b5c199bd6b1a 3571c519ada6 9c04c779f032 33d98147511b e57765dee61a 1c4ad601ef9d c30444e35f2e b9eb70fb9509 a9f1c0bf403a 5d90e0e045e2 7e01a7665aa5 62cdf34daec1 0e04dfc20211 1870e4fa9808 7ebb7052bc7f 11a2e09d6a69 f7bcee49bf24 32d97df55ab7 bff9106edcba f4d6ef418a96 35fea4237cc9 d7d182467382 c9aacc0e7dc1 d943160dafeb 180dc43ad0e0 c4e432da4bc9 9aaa88b2d9c8 f01cb865fc60 1b6536f06132 7a14bdaeb857 3776b70d9b17
- In bookmarks
- api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready 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
- 11 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +6 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Used by, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land. |
|
| 8 | + | - **Admin › Runners › Images (#146):** every runner image, where it is and what uses it. |
|
| 9 | + | - **Faster pages after a restart (#145):** bookmark summaries are kept on disk; slow requests are logged. |
|
| 10 | + | ||
| 5 | 11 | ## 0.4.2 (2026-10-11) |
|
| 6 | 12 | ||
| 7 | 13 | - **Coverage from a workstation (#136).** `oh coverage upload FILE [--bookmark B | --change HASH]` keeps an LCOV or Cobertura report measured off the hub as the coverage of that state, for suites a runner cannot run in full. The Coverage page, its history and the Reports card say "uploaded by NAME" where a run number would be, so a runner's figure and an upload's are never mistaken for each other, and an upload sets no check. |
|
| … | 159 unchanged lines |
modified crates/hub/src/diffusion.rs +180 −2
| … | 175 unchanged lines |
||
| 176 | 176 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 177 | 177 | pub enum Bumped { |
|
| 178 | 178 | /// The bump bookmark now holds the target plus one change moving the pin. |
|
| 179 | - | Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash> }, |
|
| 179 | + | Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash>, from: Vec<ChangeHash> }, |
|
| 180 | 180 | /// The pin already holds the upstream state. |
|
| 181 | 181 | UpToDate, |
|
| 182 | 182 | /// The bump bookmark already moves the pin to that state. |
|
| … | 87 unchanged lines |
||
| 270 | 270 | let upstream_label = format!("{}/{}", upstream.owner, upstream.name); |
|
| 271 | 271 | let (t, p) = (target.to_string(), path.trim_matches('/').to_string()); |
|
| 272 | 272 | let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark }; |
|
| 273 | + | let old = old.clone(); |
|
| 273 | 274 | let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect(); |
|
| 274 | 275 | let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+")); |
|
| 275 | 276 | app.repos |
|
| … | 19 unchanged lines |
||
| 295 | 296 | refused.reason |
|
| 296 | 297 | )))); |
|
| 297 | 298 | } |
|
| 298 | - | Ok(Bumped::Moved { bookmark, change: hash, heads }) |
|
| 299 | + | Ok(Bumped::Moved { bookmark, change: hash, heads, from: old }) |
|
| 299 | 300 | }) |
|
| 300 | 301 | .await |
|
| 301 | 302 | } |
|
| 302 | 303 | ||
| 304 | + | /// The account Diffusion's proposals are opened as. |
|
| 305 | + | pub const ACCOUNT: &str = "diffusion"; |
|
| 306 | + | ||
| 307 | + | /// The `diffusion` account, made on first use with no way to sign in. |
|
| 308 | + | pub async fn account(pool: &Pool) -> Option<Account> { |
|
| 309 | + | if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await { |
|
| 310 | + | return Some(a); |
|
| 311 | + | } |
|
| 312 | + | db::accounts::create_account(pool, ACCOUNT, "diffusion@localhost", "!", "", false).await.ok() |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | /// The upstream changes a bump brings: in `new` and not in `old`, newest |
|
| 316 | + | /// first, as Markdown lines linking to the upstream's change pages. |
|
| 317 | + | pub async fn changelog(app: &Shared, upstream: &RepoRow, old: Vec<ChangeHash>, new: Vec<ChangeHash>) -> Vec<String> { |
|
| 318 | + | let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { return Vec::new() }; |
|
| 319 | + | let linked: std::collections::HashMap<String, String> = |
|
| 320 | + | db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect(); |
|
| 321 | + | let base = format!("/{}/{}", upstream.owner, upstream.name); |
|
| 322 | + | app.repos |
|
| 323 | + | .read(&name, move |r, cache| { |
|
| 324 | + | let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?); |
|
| 325 | + | let mut added: Vec<(u64, ChangeHash, String, String)> = Vec::new(); |
|
| 326 | + | for h in n.changes.iter().filter(|c| !o.changes.contains(*c)) { |
|
| 327 | + | let Ok(c) = r.store().get_change(h) else { continue }; |
|
| 328 | + | let key = c.author.to_hex(); |
|
| 329 | + | let who = linked.get(&key).cloned().unwrap_or_else(|| key[..12].to_string()); |
|
| 330 | + | let first = c.message.lines().next().unwrap_or("").trim().to_string(); |
|
| 331 | + | added.push((c.hlc.wall_ms, *h, first, who)); |
|
| 332 | + | } |
|
| 333 | + | added.sort_by_key(|a| std::cmp::Reverse(a.0)); |
|
| 334 | + | Ok(added |
|
| 335 | + | .into_iter() |
|
| 336 | + | .map(|(_, h, msg, who)| { |
|
| 337 | + | format!("- [`{}`]({base}/change/{}) {} ({who})", &h.to_hex()[..12], h.to_hex(), msg.replace('\n', " ")) |
|
| 338 | + | }) |
|
| 339 | + | .collect()) |
|
| 340 | + | }) |
|
| 341 | + | .await |
|
| 342 | + | .unwrap_or_default() |
|
| 343 | + | } |
|
| 344 | + | ||
| 345 | + | /// Open the bump proposal for `bookmark`, or bring the open one up to |
|
| 346 | + | /// date: from `bookmark` into `target`, by the `diffusion` account, |
|
| 347 | + | /// labelled `dependencies`, its body the changelog. Its number. |
|
| 348 | + | pub async fn propose( |
|
| 349 | + | app: &Shared, |
|
| 350 | + | consumer: &RepoRow, |
|
| 351 | + | target: &str, |
|
| 352 | + | path: &str, |
|
| 353 | + | upstream: &RepoRow, |
|
| 354 | + | from: Vec<ChangeHash>, |
|
| 355 | + | to: Vec<ChangeHash>, |
|
| 356 | + | ) -> Option<i64> { |
|
| 357 | + | use crate::db::proposals as pdb; |
|
| 358 | + | let bookmark = bump_bookmark(path); |
|
| 359 | + | let bookmark = bookmark.as_str(); |
|
| 360 | + | let me = account(&app.db).await?; |
|
| 361 | + | let lines = changelog(app, upstream, from, to.clone()).await; |
|
| 362 | + | let slug = format!("{}/{}", upstream.owner, upstream.name); |
|
| 363 | + | let short: Vec<String> = to.iter().map(|h| h.to_hex()[..12].to_string()).collect(); |
|
| 364 | + | let title = format!( |
|
| 365 | + | "Diffusion: {path} to {slug} {} ({})", |
|
| 366 | + | short.join("+"), |
|
| 367 | + | if lines.len() == 1 { "1 change".into() } else { format!("{} changes", lines.len()) } |
|
| 368 | + | ); |
|
| 369 | + | let body = format!( |
|
| 370 | + | "Moves the subrepo `{path}` to the newest state of [{slug}](/{slug}) that {}. Only the pin changes; this repository's own checks test it.\n\n**What it brings** ({} change{}):\n\n{}\n\nOpened and kept up to date by Diffusion (docs/diffusion.md). It never lands by itself.", |
|
| 371 | + | if Follow::parse(&consumer.diffusion) == Some(Follow::Any) { "moved" } else { "passed its checks" }, |
|
| 372 | + | lines.len(), |
|
| 373 | + | if lines.len() == 1 { "" } else { "s" }, |
|
| 374 | + | lines.join("\n") |
|
| 375 | + | ); |
|
| 376 | + | let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark); |
|
| 377 | + | let number = match open { |
|
| 378 | + | Some(p) => { |
|
| 379 | + | pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?; |
|
| 380 | + | pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &short.join("+")).await.ok(); |
|
| 381 | + | p.number |
|
| 382 | + | } |
|
| 383 | + | None => { |
|
| 384 | + | let p = pdb::create( |
|
| 385 | + | &app.db, |
|
| 386 | + | pdb::NewProposal { |
|
| 387 | + | repo_id: consumer.id, |
|
| 388 | + | title: &title, |
|
| 389 | + | body: &body, |
|
| 390 | + | author_id: Some(me.id), |
|
| 391 | + | author: ACCOUNT, |
|
| 392 | + | draft: false, |
|
| 393 | + | target, |
|
| 394 | + | source_kind: "bookmark", |
|
| 395 | + | source_repo_id: None, |
|
| 396 | + | source_bookmark: bookmark, |
|
| 397 | + | source_changes: "", |
|
| 398 | + | }, |
|
| 399 | + | ) |
|
| 400 | + | .await |
|
| 401 | + | .ok()?; |
|
| 402 | + | pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok(); |
|
| 403 | + | db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok(); |
|
| 404 | + | if let Ok(label) = |
|
| 405 | + | db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await |
|
| 406 | + | { |
|
| 407 | + | db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok(); |
|
| 408 | + | } |
|
| 409 | + | p.number |
|
| 410 | + | } |
|
| 411 | + | }; |
|
| 412 | + | Some(number) |
|
| 413 | + | } |
|
| 414 | + | ||
| 415 | + | /// The text of `.oxymodules` on `bookmark` of `name`, if any. |
|
| 416 | + | async fn oxymodules(app: &Shared, name: &RepoName, bookmark: &str) -> Option<String> { |
|
| 417 | + | let b = bookmark.to_string(); |
|
| 418 | + | app.repos |
|
| 419 | + | .read(name, move |r, cache| { |
|
| 420 | + | let heads = heads_of(r, &b)?; |
|
| 421 | + | if heads.is_empty() { |
|
| 422 | + | return Ok(None); |
|
| 423 | + | } |
|
| 424 | + | let loaded = cache.load(r, &heads)?; |
|
| 425 | + | Ok(match loaded.snapshot.files.get(".oxymodules").map(|f| &f.content) { |
|
| 426 | + | Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()), |
|
| 427 | + | _ => None, |
|
| 428 | + | }) |
|
| 429 | + | }) |
|
| 430 | + | .await |
|
| 431 | + | .ok() |
|
| 432 | + | .flatten() |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | /// The upstream `upstream` reached `heads` on its default bookmark: by a |
|
| 436 | + | /// push (`green` false) or a passing run of that state (`green` true). |
|
| 437 | + | /// Bump and propose in every consumer that follows it that way (§4.3). |
|
| 438 | + | pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) { |
|
| 439 | + | let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return }; |
|
| 440 | + | let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return }; |
|
| 441 | + | let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?") |
|
| 442 | + | .bind(hex::encode(id)) |
|
| 443 | + | .fetch_all(&app.db) |
|
| 444 | + | .await |
|
| 445 | + | .unwrap_or_default(); |
|
| 446 | + | for (consumer_id, indexed, path) in rows { |
|
| 447 | + | let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue }; |
|
| 448 | + | let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue }; |
|
| 449 | + | let learned = target(&app.db, &consumer).await; |
|
| 450 | + | let modules = oxymodules(app, &cname, &learned).await; |
|
| 451 | + | let r = rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path); |
|
| 452 | + | let target = r.target.clone().unwrap_or(learned); |
|
| 453 | + | if indexed != target { |
|
| 454 | + | continue; |
|
| 455 | + | } |
|
| 456 | + | let go = match r.follow { |
|
| 457 | + | Follow::Off => false, |
|
| 458 | + | Follow::Green => green, |
|
| 459 | + | Follow::Any => !green, |
|
| 460 | + | }; |
|
| 461 | + | if !go { |
|
| 462 | + | continue; |
|
| 463 | + | } |
|
| 464 | + | match bump(app, &consumer, &target, &path, upstream, heads.clone()).await { |
|
| 465 | + | Ok(Bumped::Moved { from, .. }) => { |
|
| 466 | + | let n = propose(app, &consumer, &target, &path, upstream, from, heads.clone()).await; |
|
| 467 | + | tracing::info!( |
|
| 468 | + | "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})", |
|
| 469 | + | consumer.owner, |
|
| 470 | + | consumer.name, |
|
| 471 | + | upstream.owner, |
|
| 472 | + | upstream.name |
|
| 473 | + | ); |
|
| 474 | + | } |
|
| 475 | + | Ok(_) => {} |
|
| 476 | + | Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name), |
|
| 477 | + | } |
|
| 478 | + | } |
|
| 479 | + | } |
|
| 480 | + | ||
| 303 | 481 | /// When a consumer takes an upstream's moves (§4.2). |
|
| 304 | 482 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
|
| 305 | 483 | #[serde(rename_all = "lowercase")] |
|
| … | 142 unchanged lines |
modified crates/hub/src/flow.rs +9 −0
| … | 1540 unchanged lines |
||
| 1541 | 1541 | }; |
|
| 1542 | 1542 | report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| 1543 | 1543 | } |
|
| 1544 | + | // A passing run of an upstream's default bookmark: consumers following |
|
| 1545 | + | // it when green are bumped (Diffusion, #148). |
|
| 1546 | + | if let Some(done) = fdb::run_by_id(&app.db, run_id).await? { |
|
| 1547 | + | if done.status == "passed" && done.bookmark == row.default_bookmark { |
|
| 1548 | + | let heads: Vec<ChangeHash> = done.head_list().iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); |
|
| 1549 | + | let (app, row) = (app.clone(), row.clone()); |
|
| 1550 | + | tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, true).await }); |
|
| 1551 | + | } |
|
| 1552 | + | } |
|
| 1544 | 1553 | Ok(()) |
|
| 1545 | 1554 | } |
|
| 1546 | 1555 | ||
| … | 371 unchanged lines |
modified crates/hub/src/protocol.rs +7 −0
| … | 349 unchanged lines |
||
| 350 | 350 | }); |
|
| 351 | 351 | } |
|
| 352 | 352 | crate::diffusion::after_push(&app, &row, &moved).await; |
|
| 353 | + | // An upstream's default bookmark moved: consumers following |
|
| 354 | + | // every move are bumped now, those following green when |
|
| 355 | + | // its run passes (#148). |
|
| 356 | + | if let Some((_, _, heads)) = moved.iter().find(|(b, _, _)| *b == row.default_bookmark) { |
|
| 357 | + | let (app, row, heads) = (app.clone(), row.clone(), heads.clone()); |
|
| 358 | + | tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await }); |
|
| 359 | + | } |
|
| 353 | 360 | for (bookmark, state, heads) in &moved { |
|
| 354 | 361 | crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await; |
|
| 355 | 362 | } |
|
| … | 30 unchanged lines |
modified crates/hub/tests/flow.rs +114 −0
| … | 3235 unchanged lines |
||
| 3236 | 3236 | let left = settle(&|l| l.is_empty()).await; |
|
| 3237 | 3237 | assert!(left.is_empty(), "{left:?}"); |
|
| 3238 | 3238 | } |
|
| 3239 | + | ||
| 3240 | + | /// Diffusion D1 end to end (#148): a consumer that follows every move gets |
|
| 3241 | + | /// a bump proposal on a push; one that follows green gets it when the |
|
| 3242 | + | /// upstream's run passes. One proposal, kept up to date. |
|
| 3243 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 3244 | + | async fn diffusion_proposes_a_bump_when_the_upstream_moves() { |
|
| 3245 | + | let h = hub().await; |
|
| 3246 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 3247 | + | create(&h.app, "andy", "api").await; |
|
| 3248 | + | create(&h.app, "andy", "game").await; |
|
| 3249 | + | // Run every job of the upstream's newest run to a pass. |
|
| 3250 | + | let pass_all = || async { |
|
| 3251 | + | for _ in 0..20 { |
|
| 3252 | + | let Some(a) = claim(&h.app).await else { |
|
| 3253 | + | tokio::time::sleep(std::time::Duration::from_millis(30)).await; |
|
| 3254 | + | if !jobs_left(&h.app).await { |
|
| 3255 | + | break; |
|
| 3256 | + | } |
|
| 3257 | + | continue; |
|
| 3258 | + | }; |
|
| 3259 | + | let id = a["job_id"].as_i64().unwrap(); |
|
| 3260 | + | let (name, ph) = (a["job"]["name"].as_str().unwrap().to_string(), a["pipeline_hash"].as_str().unwrap().to_string()); |
|
| 3261 | + | assert_eq!( |
|
| 3262 | + | finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": receipt(&name, &ph, 0) })).await, |
|
| 3263 | + | StatusCode::NO_CONTENT |
|
| 3264 | + | ); |
|
| 3265 | + | } |
|
| 3266 | + | }; |
|
| 3267 | + | let api_work = tempfile::tempdir().unwrap(); |
|
| 3268 | + | let api = client(&format!("{}/andy/api", h.base), api_work.path()); |
|
| 3269 | + | write(api_work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 3270 | + | write(api_work.path(), "test.sh", "echo ok\n"); |
|
| 3271 | + | write(api_work.path(), "api.h", "v1\n"); |
|
| 3272 | + | api.snapshot("t").unwrap(); |
|
| 3273 | + | api.commit("api v1", &[]).unwrap(); |
|
| 3274 | + | assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3275 | + | pass_all().await; |
|
| 3276 | + | ||
| 3277 | + | let game_work = tempfile::tempdir().unwrap(); |
|
| 3278 | + | let game = client(&format!("{}/andy/game", h.base), game_work.path()); |
|
| 3279 | + | game.submodule_add(&format!("{}/andy/api", h.base), "deps/api", Some("main")).unwrap(); |
|
| 3280 | + | write(game_work.path(), "game.c", "int main;\n"); |
|
| 3281 | + | game.snapshot("t").unwrap(); |
|
| 3282 | + | game.commit("game on api v1", &[]).unwrap(); |
|
| 3283 | + | assert!(game.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3284 | + | let game_row = db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap(); |
|
| 3285 | + | let bump = || async { |
|
| 3286 | + | for _ in 0..100 { |
|
| 3287 | + | let open = db::proposals::list(&h.app.db, game_row.id, &["open"]).await.unwrap(); |
|
| 3288 | + | if let Some(p) = open.into_iter().find(|p| p.source_bookmark == "diffusion/deps/api") { |
|
| 3289 | + | return Some(p); |
|
| 3290 | + | } |
|
| 3291 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 3292 | + | } |
|
| 3293 | + | None |
|
| 3294 | + | }; |
|
| 3295 | + | ||
| 3296 | + | // Off by default: an upstream move proposes nothing. |
|
| 3297 | + | write(api_work.path(), "a2.c", "v2\n"); |
|
| 3298 | + | api.snapshot("t").unwrap(); |
|
| 3299 | + | api.commit("api v2: faster sprites", &[]).unwrap(); |
|
| 3300 | + | assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3301 | + | pass_all().await; |
|
| 3302 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 3303 | + | assert!(db::proposals::list(&h.app.db, game_row.id, &[]).await.unwrap().is_empty(), "off proposes nothing"); |
|
| 3304 | + | ||
| 3305 | + | // Following every move: the next push proposes, by the diffusion account. |
|
| 3306 | + | let put = |body: &'static str| call(&h.app, "PUT", "/api/v1/repos/andy/game/diffusion", Some("site"), Some(body.into())); |
|
| 3307 | + | assert_eq!(put(r#"{"follow":"any"}"#).await.0, StatusCode::OK); |
|
| 3308 | + | write(api_work.path(), "a3.c", "v3\n"); |
|
| 3309 | + | api.snapshot("t").unwrap(); |
|
| 3310 | + | api.commit("api v3: save slots past ten", &[]).unwrap(); |
|
| 3311 | + | assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3312 | + | let p = bump().await.expect("a bump proposal"); |
|
| 3313 | + | assert_eq!((p.author.as_str(), p.target.as_str()), ("diffusion", "main")); |
|
| 3314 | + | assert!(p.title.contains("deps/api") && p.title.contains("andy/api") && p.title.contains("2 changes"), "{}", p.title); |
|
| 3315 | + | assert!(p.body.contains("api v2: faster sprites") && p.body.contains("api v3: save slots past ten"), "{}", p.body); |
|
| 3316 | + | let labels = db::issues::proposal_labels(&h.app.db, p.id).await.unwrap(); |
|
| 3317 | + | assert!(labels.iter().any(|l| l.name == "dependencies")); |
|
| 3318 | + | pass_all().await; |
|
| 3319 | + | ||
| 3320 | + | // Following green: a push alone does not move it; the passing run does, |
|
| 3321 | + | // and the same proposal is brought up to date. |
|
| 3322 | + | assert_eq!(put(r#"{"follow":"green"}"#).await.0, StatusCode::OK); |
|
| 3323 | + | write(api_work.path(), "a4.c", "v4\n"); |
|
| 3324 | + | api.snapshot("t").unwrap(); |
|
| 3325 | + | api.commit("api v4: rsm_set cheaper", &[]).unwrap(); |
|
| 3326 | + | assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3327 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 3328 | + | assert!(!bump().await.unwrap().body.contains("api v4"), "not before its run passes"); |
|
| 3329 | + | pass_all().await; |
|
| 3330 | + | let mut updated = None; |
|
| 3331 | + | for _ in 0..100 { |
|
| 3332 | + | let p2 = bump().await.unwrap(); |
|
| 3333 | + | if p2.body.contains("api v4") { |
|
| 3334 | + | updated = Some(p2); |
|
| 3335 | + | break; |
|
| 3336 | + | } |
|
| 3337 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 3338 | + | } |
|
| 3339 | + | let p2 = updated.expect("brought up to date once the run passed"); |
|
| 3340 | + | assert_eq!(p2.number, p.number, "the same proposal"); |
|
| 3341 | + | assert!(p2.title.contains("3 changes"), "{}", p2.title); |
|
| 3342 | + | assert_eq!(db::proposals::list(&h.app.db, game_row.id, &[]).await.unwrap().len(), 1, "never a pile of bumps"); |
|
| 3343 | + | } |
|
| 3344 | + | ||
| 3345 | + | /// Whether any job is queued or running. |
|
| 3346 | + | async fn jobs_left(app: &Shared) -> bool { |
|
| 3347 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM flow_jobs WHERE status IN ('queued', 'running')") |
|
| 3348 | + | .fetch_one(&app.db) |
|
| 3349 | + | .await |
|
| 3350 | + | .unwrap_or(0) |
|
| 3351 | + | > 0 |
|
| 3352 | + | } |
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. **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 run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. 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). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). It is skipped when the pin already holds the state, when the bump bookmark already moves it there, or when the state lacks changes the pin has (consumers only move forward). **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 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 run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. 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). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). It is skipped when the pin already holds the state, when the bump bookmark already moves it there, or when the state lacks changes the pin has (consumers only move forward). A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 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 |