A fork starts green where upstream proved the same state: a job with no pass of its own reuses one from the repositories it was forked from, public or the fork owner's, and says whose (refs #296) 42cd46c9b09e
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d8ab353204f3
- When
- · 2026-10-27 18:52 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
42cd46c9b09e3eb804f933a8ff4ab4777ea35c17a8fd2a99eea4178d58264999- Depends on
- 0468394734e9 070a7b64b368 217814c239f0 367f34b8e590 42fa95540468 5588bb3631d2 858eca0d03c4 a7e6e8e6094c b5668108487f
- Depended on by
- 06b756358f7e 91c306aa4706 aeed63c00ad7
- In bookmarks
- main
- 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
- Edits
- 15 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A fork starts green where upstream proved the same state (#296, first slice).** Pass reuse (#265) looked in the repository's own runs only. A push's job with no pass of its own on that state now reuses one from the repositories it was forked from, nearest first (up to five up): the same state is the same changes, so upstream's receipt stands for it. Only from an upstream that is public or has the fork's owner. The run page says whose pass it was and links to it, and its check reads "passed in OWNER/NAME run #N on this state, reused". |
|
| 8 | + | ||
| 7 | 9 | - **A proposal's conflicts are resolved on the web (#337).** A proposal whose landing would conflict has RESOLVE ON THE WEB for those who may push to its source: the hub brings the target into the source bookmark, as `oxy merge TARGET` would there (signed as the user's delegate, or unsigned where no trust policy asks for more), and opens the resolver on the first conflicted file. The resolution is committed to the source, so the proposal follows it and lands. A proposal from a fork is resolved in the fork. |
|
| 8 | 10 | ||
| 9 | 11 | - **A default bookmark must exist, and bookmarks are made on the web (#345).** Settings refused nothing, so a default named before it existed (doomsdayone/tetriblox's `develop`) left the repository's pages saying `"develop" is not a change`. Settings now refuse a new default that is not a bookmark (an empty repository may name any, for its first push) and list those that are; a missing default leaves the overview showing main, or the first bookmark, with a note; an unknown name is "not a bookmark, tag or change of this repository". Bookmarks › New bookmark makes one from a bookmark, tag or change, as the user's delegate or, without a trust policy, unsigned as a push would be, so a proposal can then target it. |
|
| … | 376 unchanged lines |
modified crates/hub/src/db/flow.rs +51 −3
| … | 188 unchanged lines |
||
| 189 | 189 | ["image", "prepare", "run", "finish"].iter().filter_map(|p| m.get(*p).map(|ms| (p.to_string(), *ms))).collect() |
|
| 190 | 190 | } |
|
| 191 | 191 | ||
| 192 | - | /// The run whose pass this job reused (#265), if it did. |
|
| 192 | + | /// The run whose pass this job reused (#265), if it did: in this |
|
| 193 | + | /// repository, or in the one [`Job::reused_repo`] names (#296). |
|
| 193 | 194 | pub fn reused_from(&self) -> Option<i64> { |
|
| 194 | - | self.error.strip_prefix("reused from run #")?.parse().ok() |
|
| 195 | + | self.error.strip_prefix("reused from ")?.rsplit_once("run #")?.1.parse().ok() |
|
| 195 | 196 | } |
|
| 196 | 197 | ||
| 198 | + | /// The repository (`owner/name`) whose pass this job reused, when not |
|
| 199 | + | /// its own: a fork reusing what upstream proved (#296). |
|
| 200 | + | pub fn reused_repo(&self) -> Option<&str> { |
|
| 201 | + | let rest = self.error.strip_prefix("reused from ")?; |
|
| 202 | + | let (repo, n) = rest.rsplit_once(" run #")?; |
|
| 203 | + | n.parse::<i64>().ok()?; |
|
| 204 | + | Some(repo).filter(|r| !r.is_empty()) |
|
| 205 | + | } |
|
| 206 | + | ||
| 197 | 207 | pub fn depends_on(&self) -> Vec<String> { |
|
| 198 | 208 | serde_json::from_str(&self.depends).unwrap_or_default() |
|
| 199 | 209 | } |
|
| … | 29 unchanged lines |
||
| 229 | 239 | /// A job's earlier pass on the same state, spec and sandbox (#265). |
|
| 230 | 240 | #[derive(Clone, Debug)] |
|
| 231 | 241 | pub struct Reused { |
|
| 242 | + | /// The repository it passed in, `owner/name`, when not this one (#296). |
|
| 243 | + | pub repo: Option<String>, |
|
| 232 | 244 | /// The run it passed in. |
|
| 233 | 245 | pub run_number: i64, |
|
| 234 | 246 | pub receipt: String, |
|
| … | 6 unchanged lines |
||
| 241 | 253 | impl Reused { |
|
| 242 | 254 | /// What the reused job says, and how [`Job::reused_from`] reads it back. |
|
| 243 | 255 | pub fn note(&self) -> String { |
|
| 244 | - | format!("reused from run #{}", self.run_number) |
|
| 256 | + | match &self.repo { |
|
| 257 | + | Some(r) => format!("reused from {r} run #{}", self.run_number), |
|
| 258 | + | None => format!("reused from run #{}", self.run_number), |
|
| 259 | + | } |
|
| 245 | 260 | } |
|
| 246 | 261 | } |
|
| 247 | 262 | ||
| … | 21 unchanged lines |
||
| 269 | 284 | .fetch_optional(pool) |
|
| 270 | 285 | .await?; |
|
| 271 | 286 | Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused { |
|
| 287 | + | repo: None, |
|
| 272 | 288 | run_number, |
|
| 273 | 289 | receipt, |
|
| 274 | 290 | receipt_hash, |
|
| … | 1 unchanged lines |
||
| 276 | 292 | receipt_signer, |
|
| 277 | 293 | receipt_note, |
|
| 278 | 294 | })) |
|
| 295 | + | } |
|
| 296 | + | ||
| 297 | + | /// How far up a fork's line [`passed_upstream`] looks. |
|
| 298 | + | const UPSTREAM_DEPTH: usize = 5; |
|
| 299 | + | ||
| 300 | + | /// A pass of job `name` with this spec and sandbox on `state` in a |
|
| 301 | + | /// repository `repo` was forked from, nearest first (#296): the same state |
|
| 302 | + | /// is the same changes, so what upstream proved holds for the fork. Only |
|
| 303 | + | /// from an upstream that is public or has the fork's owner, so a private |
|
| 304 | + | /// repository's receipts never show in another owner's fork. |
|
| 305 | + | pub async fn passed_upstream( |
|
| 306 | + | pool: &Pool, |
|
| 307 | + | repo: &super::repos::RepoRow, |
|
| 308 | + | state: &str, |
|
| 309 | + | name: &str, |
|
| 310 | + | spec: &str, |
|
| 311 | + | sandbox: &str, |
|
| 312 | + | ) -> sqlx::Result<Option<Reused>> { |
|
| 313 | + | let mut parent = repo.forked_from; |
|
| 314 | + | for _ in 0..UPSTREAM_DEPTH { |
|
| 315 | + | let Some(id) = parent else { break }; |
|
| 316 | + | let Some(up) = super::repos::get_by_id(pool, id).await? else { break }; |
|
| 317 | + | parent = up.forked_from; |
|
| 318 | + | if !(up.visibility == "public" || up.owner == repo.owner) { |
|
| 319 | + | continue; |
|
| 320 | + | } |
|
| 321 | + | if let Some(mut r) = passed_on_state(pool, up.id, state, name, spec, sandbox).await? { |
|
| 322 | + | r.repo = Some(format!("{}/{}", up.owner, up.name)); |
|
| 323 | + | return Ok(Some(r)); |
|
| 324 | + | } |
|
| 325 | + | } |
|
| 326 | + | Ok(None) |
|
| 279 | 327 | } |
|
| 280 | 328 | ||
| 281 | 329 | /// Runs of a job whose peaks set its next need. |
|
| … | 1257 unchanged lines |
modified crates/hub/src/flow.rs +9 −3
| … | 837 unchanged lines |
||
| 838 | 838 | } |
|
| 839 | 839 | j.reused = |
|
| 840 | 840 | fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?; |
|
| 841 | + | // A fork starts green where upstream proved this state (#296). |
|
| 842 | + | if j.reused.is_none() && row.forked_from.is_some() { |
|
| 843 | + | j.reused = |
|
| 844 | + | fdb::passed_upstream(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?; |
|
| 845 | + | } |
|
| 841 | 846 | } |
|
| 842 | 847 | } |
|
| 843 | 848 | let hash = hex::encode(p.hash()); |
|
| … | 1636 unchanged lines |
||
| 2480 | 2485 | } |
|
| 2481 | 2486 | for j in changed { |
|
| 2482 | 2487 | let summary = match j.status.as_str() { |
|
| 2483 | - | "passed" => match j.reused_from() { |
|
| 2484 | - | Some(n) => format!("passed in run #{n} on this state, reused"), |
|
| 2485 | - | None => format!("passed in {}", duration(&j)), |
|
| 2488 | + | "passed" => match (j.reused_from(), j.reused_repo()) { |
|
| 2489 | + | (Some(n), Some(r)) => format!("passed in {r} run #{n} on this state, reused"), |
|
| 2490 | + | (Some(n), None) => format!("passed in run #{n} on this state, reused"), |
|
| 2491 | + | (None, _) => format!("passed in {}", duration(&j)), |
|
| 2486 | 2492 | }, |
|
| 2487 | 2493 | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 2488 | 2494 | "skipped" => "skipped: a dependency did not pass".into(), |
|
| … | 570 unchanged lines |
modified crates/hub/tests/flow.rs +59 −0
| … | 1994 unchanged lines |
||
| 1995 | 1995 | assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}"); |
|
| 1996 | 1996 | } |
|
| 1997 | 1997 | ||
| 1998 | + | /// oxygenhub#296: a fork starts green where upstream proved the same state: |
|
| 1999 | + | /// its push reuses upstream's passes, says whose, and links to them; never |
|
| 2000 | + | /// a private upstream's when the fork has another owner. |
|
| 2001 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2002 | + | async fn a_fork_reuses_what_upstream_proved() { |
|
| 2003 | + | let h = hub().await; |
|
| 2004 | + | for who in ["andy", "bob"] { |
|
| 2005 | + | db::accounts::create_account(&h.app.db, who, &format!("{who}@example.com"), "x", "salt", true).await.unwrap(); |
|
| 2006 | + | } |
|
| 2007 | + | create(&h.app, "andy", "game").await; |
|
| 2008 | + | create(&h.app, "bob", "game").await; |
|
| 2009 | + | let up = db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap(); |
|
| 2010 | + | let fork = db::repos::get(&h.app.db, "bob", "game").await.unwrap().unwrap(); |
|
| 2011 | + | db::repos::set_forked_from(&h.app.db, fork.id, up.id).await.unwrap(); |
|
| 2012 | + | // Flow on for the fork (the test hub allows andy's repositories only). |
|
| 2013 | + | sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork.id).execute(&h.app.db).await.unwrap(); |
|
| 2014 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2015 | + | let mut c = client(&format!("{}/andy/game", h.base), work.path()); |
|
| 2016 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 2017 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 2018 | + | c.commit("Start", &[]).unwrap(); |
|
| 2019 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2020 | + | let first = runs_of(&h.app, "andy", "game").await.remove(0); |
|
| 2021 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1") |
|
| 2022 | + | .bind(first.id) |
|
| 2023 | + | .execute(&h.app.db) |
|
| 2024 | + | .await |
|
| 2025 | + | .unwrap(); |
|
| 2026 | + | // The same changes pushed to the fork: the same state. |
|
| 2027 | + | c.config.set("remote.origin.url", &format!("{}/bob/game", h.base)).unwrap(); |
|
| 2028 | + | c.save_config().unwrap(); |
|
| 2029 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2030 | + | let run = runs_of(&h.app, "bob", "game").await.remove(0); |
|
| 2031 | + | assert_eq!(run.state, first.state); |
|
| 2032 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 2033 | + | for j in &jobs { |
|
| 2034 | + | assert_eq!((j.status.as_str(), j.reused_from(), j.reused_repo()), ("passed", Some(first.number), Some("andy/game")), "{j:?}"); |
|
| 2035 | + | assert_eq!(j.receipt_hash, format!("r-{}", j.name)); |
|
| 2036 | + | } |
|
| 2037 | + | let (_, page) = call(&h.app, "GET", &format!("/bob/game/flow/{}", run.number), None, None).await; |
|
| 2038 | + | assert!(page.contains(&format!("href=\"/andy/game/flow/{}\">andy/game run #{}</a>", first.number, first.number)), "{page}"); |
|
| 2039 | + | let checks = db::hooks::checks_for_state(&h.app.db, fork.id, &run.state).await.unwrap(); |
|
| 2040 | + | assert!(checks.iter().any(|c| c.summary.contains("passed in andy/game run #")), "{checks:?}"); |
|
| 2041 | + | ||
| 2042 | + | // A private upstream of another owner: a new fork runs its own. |
|
| 2043 | + | sqlx::query("UPDATE repositories SET visibility = 'private' WHERE id = $1").bind(up.id).execute(&h.app.db).await.unwrap(); |
|
| 2044 | + | create(&h.app, "bob", "game2").await; |
|
| 2045 | + | let fork2 = db::repos::get(&h.app.db, "bob", "game2").await.unwrap().unwrap(); |
|
| 2046 | + | db::repos::set_forked_from(&h.app.db, fork2.id, up.id).await.unwrap(); |
|
| 2047 | + | sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork2.id).execute(&h.app.db).await.unwrap(); |
|
| 2048 | + | c.config.set("remote.origin.url", &format!("{}/bob/game2", h.base)).unwrap(); |
|
| 2049 | + | c.save_config().unwrap(); |
|
| 2050 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2051 | + | let run2 = runs_of(&h.app, "bob", "game2").await.remove(0); |
|
| 2052 | + | assert_eq!(run2.state, first.state); |
|
| 2053 | + | let jobs = db::flow::jobs(&h.app.db, run2.id).await.unwrap(); |
|
| 2054 | + | assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}"); |
|
| 2055 | + | } |
|
| 2056 | + | ||
| 1998 | 2057 | #[test] |
|
| 1999 | 2058 | fn skip_markers_are_the_forges_spellings_in_any_case() { |
|
| 2000 | 2059 | use oxygenhub::flow::skip_marker; |
|
| … | 4989 unchanged lines |
modified templates/flow_run.html +1 −1
| … | 63 unchanged lines |
||
| 64 | 64 | {% endfor %}</pre> |
|
| 65 | 65 | {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %} |
|
| 66 | 66 | {% let phases = l.j.phase_list() %}{% if !phases.is_empty() %}<p class="small mute pad">Where its time went: {% for (p, ms) in phases %}{{ p }} {{ crate::web::flow::secs(ms.clone() as i64) }}{% if !loop.last %}, {% endif %}{% endfor %}.</p>{% endif %} |
|
| 67 | - | {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %} |
|
| 67 | + | {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}{% if let Some(up) = l.j.reused_repo() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state upstream, in <a href="/{{ up }}/flow/{{ n }}">{{ up }} run #{{ n }}</a>; the same state is the same changes, so that pass's receipt stands for it here.</p>{% else %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %}{% endif %} |
|
| 68 | 68 | {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting data-queued-note>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad" data-queued-note>Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| 69 | 69 | {% 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 %} |
|
| 70 | 70 | </details> |
|
| … | 3 unchanged lines |