OXYGENHUB

Turn OxygenFlow on or off per repository in settings 9008bf6c2b8c

Site administrators switch OxygenFlow on, off or back to the site default
under a repository's Settings → OxygenFlow; hub.toml's [flow] repos stays
the default and no restart is needed. Repository admins see whether pushes
start runs but cannot change it, since runs use the site's runners. The
Flow tab's "not enabled" note links to the setting. Audited as repo.flow.
Migration 0018 adds repositories.flow.

closes #22
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 21:52 UTC
Signature
✔ signed by Andy McCall
Hash
9008bf6c2b8cab8411441fc4103ce3e93d9a3ccd68af20d0a7e78ab4c244a119
Depends on
07b1a5bbf481 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 3f4b0ea6eabf 42fa95540468 64caed18236a 93be9ce4653e 941abdd6bb4c 961c10bee174 b64e2537d702 d23464c0ac60
Depended on by
40a43921ef42 166f6e9bf4f1 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb 16ee5d19d220 8dcaeeb848c6 53a0fb2e4ac6 107eaba52c9d c8684d44d549 5d90e0e045e2 1870e4fa9808 4a4400c5812d d3b048b27277 f4d6ef418a96 e71583501b1d 7edaff01d506 1f78655a0466 d0fbea2eb46e 4ac01a732552 2a83f5303458 e71846aa07b9 c889911bec12 7974adb0be66 4f6925337848 bf3d07541368 1edac4b68c6d e271a37b629a a0d4bbfbedd1
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.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 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
21 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/hub/migrations/0018_flow_setting.sql +2 −0
1 +
-- OxygenFlow per repository: 'on', 'off', or '' to follow hub.toml's [flow] repos.
2 +
ALTER TABLE repositories ADD COLUMN flow TEXT NOT NULL DEFAULT '';
modified crates/hub/src/db/repos.rs +9 −0
…
23 unchanged lines
24 24
    /// Space-separated topics (migration 0010).
25 25
    #[sqlx(default)]
26 26
    pub topics: String,
27 +
    /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018).
28 +
    #[sqlx(default)]
29 +
    pub flow: String,
27 30
    /// Counts for the tabs; filled by `get` only.
28 31
    #[sqlx(default)]
29 32
    pub open_issues: i64,
…
206 unchanged lines
236 239
/// The forks of `id`, newest activity first.
237 240
pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> {
238 241
    sqlx::query_as("SELECT * FROM repositories WHERE forked_from = ? ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await
242 +
}
243 +
244 +
/// `on`, `off`, or `` (follow hub.toml's `[flow] repos`).
245 +
pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
246 +
    sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?;
247 +
    Ok(())
239 248
}
240 249
241 250
pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
…
23 unchanged lines
modified crates/hub/src/flow.rs +11 −1
…
18 unchanged lines
19 19
/// A job token is refused this long after its job started, whatever its state.
20 20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21 21
22 +
/// Whether pushes to this repository start runs: its own setting when a
23 +
/// site administrator made one, else hub.toml's `[flow] repos`.
24 +
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25 +
    match row.flow.as_str() {
26 +
        "on" => true,
27 +
        "off" => false,
28 +
        _ => app.config.flow.allows(&row.owner, &row.name),
29 +
    }
30 +
}
31 +
22 32
/// A bookmark moved by a push: start a run when the repository is allowed
23 33
/// and the new state has a pipeline.
24 34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
25 -
    if bookmark.starts_with("tags/") || heads.is_empty() || !app.config.flow.allows(&row.owner, &row.name) {
35 +
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
26 36
        return;
27 37
    }
28 38
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
…
314 unchanged lines
modified crates/hub/src/web/flow.rs +1 −1
…
53 unchanged lines
54 54
        };
55 55
        runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r });
56 56
    }
57 -
    let enabled = app.config.flow.allows(&row.owner, &row.name);
57 +
    let enabled = crate::flow::enabled(&app, &row);
58 58
    Ok(Page(RunsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), runs, enabled, older }))
59 59
}
60 60
…
165 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
157 unchanged lines
158 158
        .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack))
159 159
        .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack))
160 160
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
161 +
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
161 162
        .route("/{owner}/{name}/issues", get(issues::list))
162 163
        .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create))
163 164
        .route("/{owner}/{name}/issue/{number}", get(issues::show))
…
413 unchanged lines
modified crates/hub/src/web/repo_settings.rs +63 −1
…
88 unchanged lines
89 89
    requirements: db::proposals::Requirements,
90 90
    hooks: Vec<super::webhooks::HookRow>,
91 91
    required_checks: String,
92 +
    /// Whether hub.toml alone would run Flow here, and whether it runs.
93 +
    flow_default: bool,
94 +
    flow_enabled: bool,
95 +
    site_admin: bool,
92 96
    notice: Option<String>,
93 97
    error: Option<String>,
94 98
}
…
16 unchanged lines
111 115
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
112 116
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
113 117
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
114 -
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, notice, error }))
118 +
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
119 +
    let flow_enabled = crate::flow::enabled(app, &row);
120 +
    let site_admin = shell.is_admin;
121 +
    Ok(Page(RepoSettingsPage {
122 +
        shell,
123 +
        repo,
124 +
        row,
125 +
        collaborators,
126 +
        teams,
127 +
        role,
128 +
        requirements,
129 +
        hooks,
130 +
        required_checks,
131 +
        flow_default,
132 +
        flow_enabled,
133 +
        site_admin,
134 +
        notice,
135 +
        error,
136 +
    }))
115 137
}
116 138
117 139
pub async fn settings(
…
245 unchanged lines
363 385
    csrf_token: String,
364 386
    #[serde(default)]
365 387
    enabled: Option<String>,
388 +
}
389 +
390 +
#[derive(Deserialize)]
391 +
pub struct FlowForm {
392 +
    #[serde(rename = "_csrf")]
393 +
    csrf_token: String,
394 +
    /// `on`, `off`, or `default`.
395 +
    flow: String,
396 +
}
397 +
398 +
/// Turn OxygenFlow on or off for one repository. Runs use the site's
399 +
/// runners, so only site administrators decide.
400 +
pub async fn flow(
401 +
    State(app): State<Shared>,
402 +
    viewer: Viewer,
403 +
    Path((owner, name)): Path<(String, String)>,
404 +
    Form(f): Form<FlowForm>,
405 +
) -> Result<Page<RepoSettingsPage>, Error> {
406 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
407 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
408 +
    csrf(&viewer, &f.csrf_token, &shell)?;
409 +
    if !viewer.is_admin() {
410 +
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
411 +
    }
412 +
    let setting = match f.flow.as_str() {
413 +
        "on" => "on",
414 +
        "off" => "off",
415 +
        "default" => "",
416 +
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
417 +
    };
418 +
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
419 +
    let label = if setting.is_empty() { "default" } else { setting };
420 +
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
421 +
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
422 +
    let notice = if crate::flow::enabled(&app, &row) {
423 +
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
424 +
    } else {
425 +
        "OxygenFlow is off for this repository."
426 +
    };
427 +
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
366 428
}
367 429
368 430
pub async fn mirror(
…
20 unchanged lines
modified crates/hub/tests/api.rs +55 −0
…
701 unchanged lines
702 702
    // The settings page shows the same list.
703 703
    assert!(call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test"));
704 704
}
705 +
706 +
/// OxygenFlow per repository: site administrators turn it on or off in
707 +
/// the repository's settings; hub.toml's `[flow] repos` is the default.
708 +
#[tokio::test(flavor = "multi_thread")]
709 +
async fn flow_is_switched_per_repository_by_site_admins() {
710 +
    let h = hub().await;
711 +
    let app = &h.app;
712 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
713 +
        .await
714 +
        .cookie
715 +
        .unwrap();
716 +
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
717 +
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
718 +
    let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
719 +
    assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos");
720 +
    let tab = call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await;
721 +
    assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings#flow"));
722 +
723 +
    let set = |value: &'static str| {
724 +
        let (app, andy) = (app.clone(), andy.clone());
725 +
        async move {
726 +
            let settings = call(&app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
727 +
            call(
728 +
                &app,
729 +
                "POST",
730 +
                "/andy/demo/settings/flow",
731 +
                None,
732 +
                Some(&andy),
733 +
                None,
734 +
                Some(&format!("_csrf={}&flow={value}", csrf_of(&settings.body))),
735 +
            )
736 +
            .await
737 +
        }
738 +
    };
739 +
    // A repository admin who is not a site admin sees the state but cannot change it.
740 +
    let id = oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
741 +
    oxygenhub::db::accounts::set_admin(&app.db, id, false).await.unwrap();
742 +
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
743 +
    assert!(settings.body.contains("do not start runs") && !settings.body.contains("name=\"flow\""));
744 +
    let r = set("on").await;
745 +
    assert!(r.body.contains("Only site administrators"), "{}", r.body);
746 +
    assert_eq!(row().await.flow, "");
747 +
748 +
    oxygenhub::db::accounts::set_admin(&app.db, id, true).await.unwrap();
749 +
    let r = set("on").await;
750 +
    assert!(r.body.contains("OxygenFlow is on"), "{}", r.body);
751 +
    assert_eq!(row().await.flow, "on");
752 +
    assert!(oxygenhub::flow::enabled(app, &row().await));
753 +
    assert!(!call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await.body.contains("not enabled"));
754 +
    set("off").await;
755 +
    assert!(!oxygenhub::flow::enabled(app, &row().await));
756 +
    set("default").await;
757 +
    assert_eq!(row().await.flow, "");
758 +
    assert!(set("sometimes").await.body.contains("Pick on, off"));
759 +
}
modified crates/hub/tests/flow.rs +22 −0
…
442 unchanged lines
443 443
    let seqs: std::collections::BTreeSet<i64> = lines.iter().map(|l| l.seq).collect();
444 444
    assert_eq!(seqs.len(), 320, "sequence numbers are unique");
445 445
}
446 +
447 +
/// A repository's own Flow setting overrides hub.toml either way.
448 +
#[tokio::test(flavor = "multi_thread")]
449 +
async fn a_repository_setting_turns_flow_on_or_off() {
450 +
    let h = hub().await;
451 +
    create(&h.app, "other", "plain").await;
452 +
    create(&h.app, "andy", "quiet").await;
453 +
    let row = db::repos::get(&h.app.db, "other", "plain").await.unwrap().unwrap();
454 +
    db::repos::set_flow(&h.app.db, row.id, "on").await.unwrap();
455 +
    let quiet = db::repos::get(&h.app.db, "andy", "quiet").await.unwrap().unwrap();
456 +
    db::repos::set_flow(&h.app.db, quiet.id, "off").await.unwrap();
457 +
    for (owner, name) in [("other", "plain"), ("andy", "quiet")] {
458 +
        let work = tempfile::tempdir().unwrap();
459 +
        let c = client(&format!("{}/{owner}/{name}", h.base), work.path());
460 +
        write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
461 +
        write(work.path(), "test.sh", "echo ok\n");
462 +
        c.commit("Add a pipeline", &[]).unwrap();
463 +
        assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
464 +
    }
465 +
    assert_eq!(runs_of(&h.app, "other", "plain").await.len(), 1, "turned on");
466 +
    assert!(runs_of(&h.app, "andy", "quiet").await.is_empty(), "turned off despite andy/*");
467 +
}
modified docs/spec.md +2 −1
…
416 unchanged lines
417 417
418 418
Pipelines run in **OxygenFlow**, a separate project and specification (`oxygenflow/docs/spec.md`; the first slice is `oxygenflow/docs/m0.md`). A pipeline is `.flow/pipeline.kdl` in the state being built; jobs run in Flow's isolation tiers (MicroVM by default, a syscall sandbox, WASM), never in containers on the hub. The hub's side:
419 419
420 -
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs; empty means none) and `job_timeout_minutes` (60).
420 +
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) and `job_timeout_minutes` (60).
421 +
- **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart.
421 422
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state: the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs.
422 423
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
423 424
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
…
153 unchanged lines
modified templates/flow_runs.html +1 −1
…
9 unchanged lines
10 10
  {% if enabled %}
11 11
  <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs.</p>
12 12
  {% else %}
13 -
  <p>OxygenFlow is not enabled for this repository. A site administrator lists it under <code>[flow] repos</code> in <code>hub.toml</code>.</p>
13 +
  <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings#flow">Settings → OxygenFlow</a>.</p>
14 14
  {% endif %}
15 15
<pre class="snippet">schema-version 1
16 16
…
32 unchanged lines
modified templates/repo_settings.html +17 −0
…
13 unchanged lines
14 14
  <a href="#landing">Landing requirements</a>
15 15
  <a href="#git-mirror">Git mirror</a>
16 16
  <a href="#checks">Required checks</a>
17 +
  <a href="#flow">OxygenFlow</a>
17 18
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
18 19
  <a href="#collaborators">Collaborators</a>
19 20
  <a href="#danger">Delete</a>{% endif %}
…
37 unchanged lines
57 58
  <label for="required_checks">Check names that must pass on a proposal's projected state (comma-separated)</label><input type="text" id="required_checks" name="required_checks" value="{{ required_checks }}" placeholder="build, test">
58 59
  <p class="small mute">Any token with <code>checks:write</code> reports checks with <code>POST {{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/checks</code>.</p>
59 60
  <button type="submit">SAVE</button>
61 +
</form>
62 +
<form class="card wide" id="flow" method="post" action="{{ repo.url }}/settings/flow">
63 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
64 +
  <h2>OxygenFlow</h2>
65 +
  <p>Pushes to this repository {% if flow_enabled %}<strong>start runs</strong>{% else %}<strong>do not start runs</strong>{% endif %} of its <code>.flow/pipeline.kdl</code>.</p>
66 +
  {% if site_admin %}
67 +
  <div class="choice">
68 +
    <label><input type="radio" name="flow" value="on"{% if row.flow == "on" %} checked{% endif %}> on</label>
69 +
    <label><input type="radio" name="flow" value="off"{% if row.flow == "off" %} checked{% endif %}> off</label>
70 +
    <label><input type="radio" name="flow" value="default"{% if row.flow.is_empty() %} checked{% endif %}> site default ({% if flow_default %}on{% else %}off{% endif %}, from <code>[flow] repos</code> in <code>hub.toml</code>)</label>
71 +
  </div>
72 +
  <p class="small mute">Runs use the site's runners, so only site administrators change this. It takes effect at the next push.</p>
73 +
  <button type="submit">SAVE</button>
74 +
  {% else %}
75 +
  <p class="small mute">A site administrator turns OxygenFlow on or off here.</p>
76 +
  {% endif %}
60 77
</form>
61 78
{% if role == crate::auth::Role::Admin %}
62 79
{% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %}
…
32 unchanged lines