OXYGENHUB

Run pipeline from bookmarks and changes, and say why it is unavailable 4f6925337848

Run pipeline is a primary button on the Flow tab. Viewers without it are
told why: Flow is off, they are not signed in, or they lack write access.
Each bookmark has Run pipeline beside it. A change page runs the state
whose only head is that change; such runs carry no bookmark and are
labelled "change". POST /flow/runs takes {"change"} too. Runs still only
take committed states in the repository.

closes #40
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 09:41 UTC
Signature
✔ signed by Andy McCall
Hash
4f6925337848640d000d79db6b828d4855b36f694db276cb9474a253fd7d2a46
Depends on
145306044896 1edac4b68c6d 42fa95540468 9008bf6c2b8c 90326c9601f9 93be9ce4653e 9df855f29948 a0d4bbfbedd1 af9c6ca11c49 bf3d07541368 cc19697f8871 d23464c0ac60 e271a37b629a
Depended on by
367f34b8e590 c7067c7bda56 a7aa76ac52d7 b644e1757711 d9b3b84605a4 0650d94ede82 348f8c4baccf c889911bec12 df15b74774ca
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
41 atoms, 0 tree operations, 12 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +16 −4
…
1541 unchanged lines
1542 1542
    /// Default: the repository's default bookmark.
1543 1543
    #[serde(default)]
1544 1544
    bookmark: Option<String>,
1545 +
    /// A change's full hash: run the state whose only head it is, instead
1546 +
    /// of a bookmark's.
1547 +
    #[serde(default)]
1548 +
    change: Option<String>,
1545 1549
}
1546 1550
1547 1551
fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) {
…
2 unchanged lines
1550 1554
}
1551 1555
1552 1556
/// `POST /repos/{o}/{n}/flow/runs` (repo:write): run a bookmark's current
1553 -
/// state without moving it (hub spec §13.2).
1557 +
/// state, or one change's, without moving anything (hub spec §13.2).
1554 1558
async fn start_flow_run(
1555 1559
    State(app): State<Shared>,
1556 1560
    headers: HeaderMap,
…
3 unchanged lines
1560 1564
    let c = caller(&app, &headers).await?;
1561 1565
    let me = c.require("repo:write")?.clone();
1562 1566
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1563 -
    let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
1564 -
    let n = crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1565 -
    db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {bookmark}")).await.ok();
1567 +
    let (n, what) = match body.change.filter(|c| !c.trim().is_empty()) {
1568 +
        Some(change) => {
1569 +
            let n = crate::flow::run_change(&app, &row, &change, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1570 +
            (n, format!("change {}", change.trim()))
1571 +
        }
1572 +
        None => {
1573 +
            let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
1574 +
            (crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?, bookmark)
1575 +
        }
1576 +
    };
1577 +
    db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok();
1566 1578
    Ok(started(&app, &row, n))
1567 1579
}
1568 1580
…
80 unchanged lines
modified crates/hub/src/flow.rs +18 −0
…
64 unchanged lines
65 65
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
66 66
}
67 67
68 +
/// "Run pipeline" on one committed change: the state whose only head is
69 +
/// `change`, whatever bookmarks hold it. Returns the new run's number.
70 +
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
71 +
    if !enabled(app, row) {
72 +
        return Err("OxygenFlow is not enabled for this repository".into());
73 +
    }
74 +
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
75 +
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
76 +
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change(&h).is_ok())).await.map_err(|e| e.to_string())?;
77 +
    if !known {
78 +
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
79 +
    }
80 +
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
81 +
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
82 +
        .await?
83 +
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
84 +
}
85 +
68 86
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
69 87
/// run whose checks replace the old ones. Returns the new run's number.
70 88
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
…
501 unchanged lines
modified crates/hub/src/web/flow.rs +31 −6
…
31 unchanged lines
32 32
    can_run: bool,
33 33
    bookmarks: Vec<String>,
34 34
    error: Option<String>,
35 +
    /// Why this viewer is not offered "Run pipeline", when they are not.
36 +
    why_not: Option<String>,
35 37
}
36 38
37 39
#[derive(Deserialize)]
…
3 unchanged lines
41 43
    error: Option<String>,
42 44
}
43 45
46 +
/// Why `viewer` may not start runs here, or `None` when they may.
47 +
pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> {
48 +
    if !enabled {
49 +
        Some("Flow is off for this repository; a site administrator can turn it on in Settings → Flow.".into())
50 +
    } else if !signed_in {
51 +
        Some("Sign in with write access to run the pipeline.".into())
52 +
    } else if role < Role::Write {
53 +
        Some("Running the pipeline needs write access to this repository.".into())
54 +
    } else {
55 +
        None
56 +
    }
57 +
}
58 +
44 59
const PER_PAGE: i64 = 30;
45 60
46 61
pub async fn runs(
…
19 unchanged lines
66 81
        runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r, place });
67 82
    }
68 83
    let enabled = crate::flow::enabled(&app, &row);
69 -
    let can_run = enabled && viewer.account().is_some() && role >= Role::Write;
84 +
    let why_not = why_not_run(enabled, viewer.account().is_some(), role);
85 +
    let can_run = why_not.is_none();
70 86
    let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] };
71 87
    Ok(Page(RunsPage {
72 88
        shell,
…
4 unchanged lines
77 93
        can_run,
78 94
        bookmarks,
79 95
        error: q.error,
96 +
        why_not,
80 97
    }))
81 98
}
82 99
…
9 unchanged lines
92 109
pub struct StartForm {
93 110
    #[serde(rename = "_csrf")]
94 111
    csrf_token: String,
112 +
    #[serde(default)]
95 113
    bookmark: String,
114 +
    /// A change's full hash: run the state whose only head it is.
115 +
    #[serde(default)]
116 +
    change: String,
96 117
}
97 118
98 -
/// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark.
119 +
/// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark's
120 +
/// current state, or on one committed change.
99 121
pub async fn start(
100 122
    State(app): State<Shared>,
101 123
    viewer: Viewer,
…
4 unchanged lines
106 128
    let me = require(&viewer, &shell)?;
107 129
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
108 130
    csrf(&viewer, &f.csrf_token, &shell)?;
109 -
    Ok(match crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await {
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())
133 +
    } else {
134 +
        (crate::flow::run_change(&app, &row, &f.change, &me.account.username).await, format!("change {}", f.change.trim()))
135 +
    };
136 +
    Ok(match started {
110 137
        Ok(n) => {
111 -
            db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {}", f.bookmark.trim()))
112 -
                .await
113 -
                .ok();
138 +
            db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok();
114 139
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
115 140
        }
116 141
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
…
429 unchanged lines
modified crates/hub/src/web/history.rs +13 −1
…
172 unchanged lines
173 173
    ignore_ws: bool,
174 174
    /// Receipts runners signed for the state whose only head is this change.
175 175
    receipts: Vec<super::flow::ReceiptRow>,
176 +
    /// Whether the viewer may run the pipeline on this change.
177 +
    can_run: bool,
176 178
}
177 179
178 180
/// `?w=1` on a diff page hides whitespace-only changes.
…
41 unchanged lines
220 222
        Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await,
221 223
        None => vec![],
222 224
    };
223 -
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws, receipts }))
225 +
    let can_run = super::flow::why_not_run(crate::flow::enabled(&app, &row), viewer.account().is_some(), role).is_none();
226 +
    Ok(Page(ChangePage {
227 +
        shell,
228 +
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
229 +
        c,
230 +
        files,
231 +
        body_html,
232 +
        ignore_ws,
233 +
        receipts,
234 +
        can_run,
235 +
    }))
224 236
}
225 237
226 238
#[derive(Template)]
…
110 unchanged lines
modified crates/hub/src/web/refs.rs +12 −1
…
16 unchanged lines
17 17
    bookmarks: Vec<view::BookmarkView>,
18 18
    /// Whether the viewer may delete bookmarks (write access).
19 19
    can_delete: bool,
20 +
    /// Whether the viewer may run the pipeline of a bookmark.
21 +
    can_run: bool,
20 22
    notice: Option<String>,
21 23
    error: Option<String>,
22 24
}
…
11 unchanged lines
34 36
    let bookmarks =
35 37
        app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
36 38
    let can_delete = shell.user.is_some() && role >= Role::Write;
37 -
    Ok(Page(BookmarksPage { shell, repo: RepoShell::new(app, &row, "bookmarks").with_role(role), bookmarks, can_delete, notice, error }))
39 +
    let can_run = super::flow::why_not_run(crate::flow::enabled(app, &row), shell.user.is_some(), role).is_none();
40 +
    Ok(Page(BookmarksPage {
41 +
        shell,
42 +
        repo: RepoShell::new(app, &row, "bookmarks").with_role(role),
43 +
        bookmarks,
44 +
        can_delete,
45 +
        can_run,
46 +
        notice,
47 +
        error,
48 +
    }))
38 49
}
39 50
40 51
pub async fn bookmarks(
…
164 unchanged lines
modified crates/hub/tests/flow.rs +27 −3
…
594 unchanged lines
595 595
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow/2", None, None).await;
596 596
    assert!(page.contains("started by andy"), "the run page says what started it");
597 597
598 +
    // Run pipeline on one change: its state, with no bookmark.
599 +
    let head = first[0].head_list()[0].to_string();
600 +
    let (st, body) =
601 +
        call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(format!(r#"{{"change":"{head}"}}"#))).await;
602 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
603 +
    let on_change = db::flow::run(&h.app.db, first[0].repo_id, 3).await.unwrap().unwrap();
604 +
    assert_eq!(
605 +
        (on_change.bookmark.as_str(), on_change.heads.as_str(), on_change.state.as_str()),
606 +
        ("", head.as_str(), first[0].state.as_str())
607 +
    );
608 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow/3", None, None).await;
609 +
    assert!(page.contains("title=\"Run on one change, not a bookmark\">change</span>"), "run page");
610 +
    let (st, body) =
611 +
        call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(format!(r#"{{"change":"{}"}}"#, "ab".repeat(32))))
612 +
            .await;
613 +
    assert!(st == StatusCode::CONFLICT && body.contains("no change"), "{st} {body}");
614 +
    // Viewers who cannot start runs are told why, and get no button.
615 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow", None, None).await;
616 +
    assert!(page.contains("Sign in with write access to run the pipeline.") && !page.contains("primary-btn"));
617 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/bookmarks", None, None).await;
618 +
    assert!(!page.contains("/flow/start"), "no run buttons for anonymous viewers");
619 +
598 620
    // A live run is not re-run; unknown bookmarks and runs are refused.
599 621
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await;
600 622
    assert!(st == StatusCode::CONFLICT && body.contains("still queued"), "{st} {body}");
…
15 unchanged lines
616 638
    }
617 639
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await;
618 640
    assert_eq!(st, StatusCode::CREATED, "{body}");
619 -
    let third = db::flow::run(&h.app.db, row.id, 3).await.unwrap().unwrap();
641 +
    let third = db::flow::run(&h.app.db, row.id, 4).await.unwrap().unwrap();
620 642
    assert_eq!((third.trigger.as_str(), third.status.as_str(), third.state.as_str()), ("rerun:1", "queued", first[0].state.as_str()));
621 643
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, &third.state).await.unwrap();
622 644
    assert!(
623 -
        checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/3")),
645 +
        checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/4")),
624 646
        "the re-run's checks replace the old ones: {checks:?}"
625 647
    );
626 -
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow/3", None, None).await;
648 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow/4", None, None).await;
627 649
    assert!(page.contains("re-run of #1 by andy"));
628 650
    assert!(!page.contains("RE-RUN"), "anonymous viewers get no button");
629 651
…
1 unchanged lines
631 653
    db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap();
632 654
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await;
633 655
    assert!(st == StatusCode::CONFLICT && body.contains("not enabled"), "{body}");
656 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/flow", None, None).await;
657 +
    assert!(page.contains("Flow is off for this repository"), "and the Flow tab says so");
634 658
}
635 659
636 660
/// The Flow API (hub spec §13.2): runs, a run with its jobs, a job's log,
…
154 unchanged lines
modified docs/spec.md +8 −1
…
421 unchanged lines
422 422
423 423
- **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).
424 424
- **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.
425 -
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): 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. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** on the Flow tab runs a bookmark's current state, and **Re-run** on a finished run's page runs its state again. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
425 +
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): 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. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** runs a committed state without a push:
426 +
  - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state.
427 +
  - On a change page, it runs the state whose only head is that change. That run has no bookmark and is labelled "change".
428 +
  - The API does the same with `POST /flow/runs` and `{"bookmark"}` or `{"change"}`.
429 +
430 +
  Viewers who may not start runs get no button. The Flow tab says why: Flow is off for the repository, they are not signed in, or they lack write access. **Re-run** on a finished run's page runs its state again.
431 +
432 +
  Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
426 433
- **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`.
427 434
- **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.
428 435
- **Signed receipts** (Oxygen spec §14.6): `finish` may carry `signed_receipt`, the runner's signed receipt as hex. The hub keeps it in the repository when four things hold:
…
165 unchanged lines
modified templates/bookmarks.html +1 −1
…
14 unchanged lines
15 15
  <td>{% for h in b.heads %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}{% if b.heads.len() > 1 %}<span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}</td>
16 16
  <td>{{ b.changes }}</td>
17 17
  <td><time title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></td>
18 -
  <td class="small"><a href="{{ repo.url }}/changes/{{ b.name }}">history</a>{% if !b.default %} · <a href="{{ repo.url }}/compare/{{ repo.default_bookmark }}...{{ b.name }}">compare with {{ repo.default_bookmark }}</a>{% if can_delete %}
18 +
  <td class="small">{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small" title="Run the pipeline of {{ b.name }}'s current state">Run pipeline</button></form> · {% endif %}<a href="{{ repo.url }}/changes/{{ b.name }}">history</a>{% if !b.default %} · <a href="{{ repo.url }}/compare/{{ repo.default_bookmark }}...{{ b.name }}">compare with {{ repo.default_bookmark }}</a>{% if can_delete %}
19 19
    <form class="inline" method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small danger-btn" onclick="return confirm('Delete the bookmark {{ b.name }}? Its changes stay while anything else reaches them.')">DELETE</button></form>{% endif %}{% endif %}</td>
20 20
</tr>
21 21
{% endfor %}
…
2 unchanged lines
modified templates/change.html +1 −1
…
19 unchanged lines
20 20
    {% if !receipts.is_empty() %}<dt>Receipts</dt><dd class="receipts">{% for r in receipts %}<div><span class="flow-glyph st-{% if r.passed() %}passed{% else %}failed{% endif %}">{% if r.passed() %}✔{% else %}✘{% endif %}</span> {% if r.run.is_empty() %}<strong>{{ r.job }}</strong>{% else %}<a href="{{ r.run }}"><strong>{{ r.job }}</strong></a>{% endif %} <span class="small mute">{% if r.passed() %}passed{% else %}exit {{ r.exit_code }}{% endif %} in {{ r.duration }} · signed by {{ r.runner }} · <time title="{{ r.made_ms|when }}">{{ r.made_ms|ago }}</time></span>{% if !r.problem.is_empty() %} <span class="small warn">{{ r.problem }}</span>{% endif %} <a class="small" href="{{ repo.url }}/receipts/{{ r.attest }}" title="The signed receipt, for oxy receipt verify">download</a></div>{% endfor %}</dd>{% endif %}
21 21
    <dt>Edits</dt><dd>{{ c.atoms|plural("atom", "atoms") }}, {{ c.tree_ops|plural("tree operation", "tree operations") }}, {{ files.len()|plural("file", "files") }}</dd>
22 22
  </dl>
23 -
  <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a></p>
23 +
  <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a>{% if can_run %} · <form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="change" value="{{ c.view.hash }}"><button type="submit" class="small" title="Run the pipeline on the state whose only head is this change">Run pipeline on this change</button></form>{% endif %}</p>
24 24
  <nav class="files small">{% for f in files %}<a href="#{{ f.anchor }}">{{ f.path }}</a>{% endfor %}</nav>
25 25
  {% call m::diff(files, ignore_ws, "") %}
26 26
</article>
…
1 unchanged lines
modified templates/flow_run.html +1 −1
…
6 unchanged lines
7 7
{% block content %}
8 8
<div class="head">
9 9
  <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.number }} <small data-run-status>{{ run.status }}</small></h1>
10 -
  <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · {{ run.started_by() }} <time title="{{ run.created_ms|when_i }}">{{ run.created_ms|ago_i }}</time> · {{ duration }}{% if !run.pipeline_hash.is_empty() %} · pipeline <code title="{{ run.pipeline_hash }}">{{ run.pipeline_hash|short }}</code>{% endif %} · <a href="{{ repo.url }}/file/{{ run.heads }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a> · <a href="{{ repo.url }}/flow">all runs</a></p>
10 +
  <p class="small mute">{% if run.bookmark.is_empty() %}<span class="tag" title="Run on one change, not a bookmark">change</span>{% else %}<span class="lbl lbl-bm">{{ run.bookmark }}</span>{% endif %} at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · {{ run.started_by() }} <time title="{{ run.created_ms|when_i }}">{{ run.created_ms|ago_i }}</time> · {{ duration }}{% if !run.pipeline_hash.is_empty() %} · pipeline <code title="{{ run.pipeline_hash }}">{{ run.pipeline_hash|short }}</code>{% endif %} · <a href="{{ repo.url }}/file/{{ run.heads }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a> · <a href="{{ repo.url }}/flow">all runs</a></p>
11 11
  {% if can_rerun %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/rerun"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">RE-RUN</button></form>{% endif %}
12 12
</div>
13 13
{% if let Some(q) = queue %}
…
30 unchanged lines
modified templates/flow_runs.html +4 −3
…
4 unchanged lines
5 5
{% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %}
6 6
{% block content %}
7 7
<div class="head"><h1>Flow <small>pipeline runs</small></h1>
8 -
{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="small">RUN PIPELINE</button></form>{% endif %}
8 +
{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="primary-btn">Run pipeline</button></form>{% endif %}
9 9
</div>
10 +
{% if let Some(w) = why_not %}<p class="small mute">{{ w }}</p>{% endif %}
10 11
{% if let Some(e) = error %}<p class="error">Could not start a run: {{ e }}</p>{% endif %}
11 12
{% if runs.is_empty() %}
12 13
<section class="panel">
13 14
  {% if enabled %}
14 -
  <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>
15 +
  <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. Once it is there, <strong>Run pipeline</strong> above runs a bookmark's current state without a push, as does the button beside each bookmark and on each change.</p>
15 16
  {% else %}
16 17
  <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>
17 18
  {% endif %}
…
19 unchanged lines
37 38
    <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td>
38 39
    <td><a href="{{ repo.url }}/flow/{{ row.r.number }}"><strong>#{{ row.r.number }}</strong></a> <span class="small mute">{{ row.r.status }}{% if let Some(p) = row.place %}, {{ p }}{% endif %}</span></td>
39 40
    <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td>
40 -
    <td><span class="lbl lbl-bm">{{ row.r.bookmark }}</span></td>
41 +
    <td>{% if row.r.bookmark.is_empty() %}<span class="tag" title="Run on one change, not a bookmark">change</span>{% else %}<span class="lbl lbl-bm">{{ row.r.bookmark }}</span>{% endif %}</td>
41 42
    <td><a href="{{ repo.url }}/tree/{{ row.r.heads }}" title="state {{ row.r.state }}"><code>{{ row.r.state|short }}</code></a></td>
42 43
    <td>{{ row.r.actor }}</td>
43 44
    <td><time title="{{ row.r.created_ms|when_i }}">{{ row.r.created_ms|ago_i }}</time></td>
…
8 unchanged lines
modified templates/style.css +2 −0
…
323 unchanged lines
324 324
.cm-host { border: 1px solid var(--line); margin: 8px 0; min-height: 360px; max-height: 70vh; overflow: auto; font-size: 13px; }
325 325
.cm-host .cm-editor { min-height: 360px; }
326 326
button.danger-btn { color: var(--bad); border-color: var(--bad); }
327 +
button.primary-btn { background: var(--acc); color: var(--now-ink); }
328 +
button.primary-btn:hover { filter: brightness(1.1); }
327 329
button:disabled { opacity: .45; cursor: not-allowed; }
328 330
329 331
/* EasyMDE in the hub's colours */
…
192 unchanged lines