OXYGENHUB

Flow: start one manual job, with the jobs it depends on 0650d94ede82

POST /flow/runs takes "jobs": [NAME..] and oh run start --job NAME
(repeatable): a manual run of only those jobs and what they depend on,
so asking for a report does not start an hour-long test job too. A job
the pipeline lacks, or one no manual run starts, is refused with the
names of those that are.

closes #130
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 11:29 UTC
Signature
✔ signed by Andy McCall
Hash
0650d94ede82feeb89b960cb8dcfe5b99d5830ad61369b4ae67f9629144c9779
Depends on
1edac4b68c6d 348f8c4baccf 42fa95540468 4f6925337848 5c6e92fd8d71 93be9ce4653e b1e92a6233ee c889911bec12 d23464c0ac60 d7ce41acf22c
Depended on by
40dd3f86379a
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
25 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +14 −3
…
1928 unchanged lines
1929 1929
    /// of a bookmark's.
1930 1930
    #[serde(default)]
1931 1931
    change: Option<String>,
1932 +
    /// Only these jobs, with what they depend on (oxygenhub#130); default:
1933 +
    /// every job a manual run starts.
1934 +
    #[serde(default)]
1935 +
    jobs: Vec<String>,
1932 1936
}
1933 1937
1934 1938
fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) {
…
12 unchanged lines
1947 1951
    let c = caller(&app, &headers).await?;
1948 1952
    let me = c.require("repo:write")?.clone();
1949 1953
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1950 -
    let (n, what) = match body.change.filter(|c| !c.trim().is_empty()) {
1954 +
    let (n, what) = match body.change.clone().filter(|c| !c.trim().is_empty()) {
1951 1955
        Some(change) => {
1952 -
            let n = crate::flow::run_change(&app, &row, &change, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1956 +
            let n = crate::flow::run_change(&app, &row, &change, &me.username, &body.jobs)
1957 +
                .await
1958 +
                .map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1953 1959
            (n, format!("change {}", change.trim()))
1954 1960
        }
1955 1961
        None => {
1956 1962
            let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
1957 -
            (crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?, bookmark)
1963 +
            (
1964 +
                crate::flow::run_bookmark(&app, &row, &bookmark, &me.username, &body.jobs)
1965 +
                    .await
1966 +
                    .map_err(|m| ApiError(StatusCode::CONFLICT, m))?,
1967 +
                bookmark,
1968 +
            )
1958 1969
        }
1959 1970
    };
1960 1971
    db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok();
…
413 unchanged lines
modified crates/hub/src/flow.rs +29 −13
…
53 unchanged lines
54 54
55 55
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
56 56
/// moving it. Returns the new run's number.
57 -
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
57 +
/// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with
58 +
/// the jobs they depend on.
59 +
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
58 60
    if !enabled(app, row) {
59 61
        return Err("OxygenFlow is not enabled for this repository".into());
60 62
    }
…
11 unchanged lines
72 74
    if heads.is_empty() {
73 75
        return Err(format!("{bookmark} has no changes yet"));
74 76
    }
75 -
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
77 +
    start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs)
76 78
        .await?
77 79
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
78 80
}
79 81
80 82
/// "Run pipeline" on one committed change: the state whose only head is
81 83
/// `change`, whatever bookmarks hold it. Returns the new run's number.
82 -
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
84 +
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> {
83 85
    if !enabled(app, row) {
84 86
        return Err("OxygenFlow is not enabled for this repository".into());
85 87
    }
…
4 unchanged lines
90 92
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
91 93
    }
92 94
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
93 -
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
95 +
    start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs)
94 96
        .await?
95 97
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
96 98
}
…
172 unchanged lines
269 271
    trigger: &str,
270 272
    touched: Option<&[String]>,
271 273
) -> Result<Option<i64>, String> {
272 -
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None).await
274 +
    start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await
273 275
}
274 276
275 -
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock.
277 +
/// [`start_run_with`], for a scheduled run due at `at` on the hub's clock;
278 +
/// `only`, when not empty, the jobs a manual run starts (oxygenhub#130).
276 279
#[allow(clippy::too_many_arguments)]
277 280
async fn start_run_at(
278 281
    app: &Shared,
…
5 unchanged lines
284 287
    trigger: &str,
285 288
    touched: Option<&[String]>,
286 289
    at: Option<flow_core::pipeline::When>,
290 +
    only: &[String],
287 291
) -> Result<Option<i64>, String> {
288 292
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
289 293
    let h = heads.to_vec();
…
28 unchanged lines
318 322
                Some(w) => flow_core::pipeline::Event::ScheduleAt(w),
319 323
                None => event_of(trigger, bookmark),
320 324
            };
321 -
            let mut keep: std::collections::HashSet<String> = p
322 -
                .order()
323 -
                .into_iter()
324 -
                .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched))
325 -
                .map(|j| j.name.clone())
326 -
                .collect();
325 +
            let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched);
326 +
            let mut keep: std::collections::HashSet<String> = if only.is_empty() {
327 +
                p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect()
328 +
            } else {
329 +
                // Named jobs (oxygenhub#130): each one this event starts.
330 +
                let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect();
331 +
                for name in only {
332 +
                    if !startable.contains(&name.as_str()) {
333 +
                        let what = if p.jobs.contains_key(name) {
334 +
                            "is not started by hand (no `on { manual }`)"
335 +
                        } else {
336 +
                            "is not in the pipeline"
337 +
                        };
338 +
                        return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", ")));
339 +
                    }
340 +
                }
341 +
                only.iter().cloned().collect()
342 +
            };
327 343
            loop {
328 344
                let needed: Vec<String> = p
329 345
                    .order()
…
910 unchanged lines
1240 1256
        if heads.is_empty() {
1241 1257
            continue;
1242 1258
        }
1243 -
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when)).await {
1259 +
        match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await {
1244 1260
            Ok(Some(_)) => started += 1,
1245 1261
            Ok(None) => {}
1246 1262
            Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name),
…
277 unchanged lines
modified crates/hub/src/web/flow.rs +2 −2
…
128 unchanged lines
129 129
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
130 130
    csrf(&viewer, &f.csrf_token, &shell)?;
131 131
    let (started, what) = if f.change.trim().is_empty() {
132 -
        (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await, f.bookmark.trim().to_string())
132 +
        (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username, &[]).await, f.bookmark.trim().to_string())
133 133
    } else {
134 -
        (crate::flow::run_change(&app, &row, &f.change, &me.account.username).await, format!("change {}", f.change.trim()))
134 +
        (crate::flow::run_change(&app, &row, &f.change, &me.account.username, &[]).await, format!("change {}", f.change.trim()))
135 135
    };
136 136
    Ok(match started {
137 137
        Ok(n) => {
…
610 unchanged lines
modified crates/hub/tests/flow.rs +12 −0
…
668 unchanged lines
669 669
    assert!(page.contains("re-run of #1 by andy"));
670 670
    assert!(!page.contains("RE-RUN"), "anonymous viewers get no button");
671 671
672 +
    // oxygenhub#130: one job, with what it depends on; an unknown one is
673 +
    // refused, naming the jobs a manual run can start.
674 +
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(r#"{"jobs":["test"]}"#.into())).await;
675 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
676 +
    let n = serde_json::from_str::<serde_json::Value>(&body).unwrap()["number"].as_i64().unwrap();
677 +
    let only = db::flow::run(&h.app.db, first[0].repo_id, n).await.unwrap().unwrap();
678 +
    let mut names: Vec<String> = db::flow::jobs(&h.app.db, only.id).await.unwrap().into_iter().map(|j| j.name).collect();
679 +
    names.sort();
680 +
    assert_eq!(names, ["build", "test"], "test and the job it depends on, not lint");
681 +
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(r#"{"jobs":["deploy"]}"#.into())).await;
682 +
    assert!(st == StatusCode::CONFLICT && body.contains("not in the pipeline") && body.contains("build, lint, test"), "{st} {body}");
683 +
672 684
    // With Flow off, nothing starts.
673 685
    db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap();
674 686
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await;
…
1678 unchanged lines
modified crates/oh/src/main.rs +8 −2
…
463 unchanged lines
464 464
        /// A change's full hash
465 465
        #[arg(long, conflicts_with = "bookmark")]
466 466
        change: Option<String>,
467 +
        /// Only this job, with the jobs it depends on (repeatable; default: every job a manual run starts)
468 +
        #[arg(long = "job", value_name = "NAME")]
469 +
        jobs: Vec<String>,
467 470
        /// Wait for the run and exit 1 unless it passes
468 471
        #[arg(long)]
469 472
        watch: bool,
…
568 unchanged lines
1038 1041
            }
1039 1042
            print_run(&c.get(&path)?);
1040 1043
        }
1041 -
        RunCmd::Start { bookmark, change, watch: follow } => {
1042 -
            let body = match (bookmark, change) {
1044 +
        RunCmd::Start { bookmark, change, jobs, watch: follow } => {
1045 +
            let mut body = match (bookmark, change) {
1043 1046
                (_, Some(ch)) => serde_json::json!({ "change": ch }),
1044 1047
                (Some(b), None) => serde_json::json!({ "bookmark": b }),
1045 1048
                (None, None) => serde_json::json!({}),
1046 1049
            };
1050 +
            if !jobs.is_empty() {
1051 +
                body["jobs"] = serde_json::json!(jobs);
1052 +
            }
1047 1053
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs"), &body)?;
1048 1054
            let n = started["number"].as_i64().context("the hub named no run")?;
1049 1055
            if !json || !follow {
…
428 unchanged lines
modified docs/spec.md +1 −1
…
459 unchanged lines
460 460
461 461
### 12.1 API
462 462
463 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
463 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
464 464
465 465
### 12.2 Webhooks
466 466
…
285 unchanged lines