OXYGENHUB

Flow state stamps: a state-addressed, verifiable pipeline result for READMEs, and a raw route so Markdown images work 736f6813543d

closes #1
closes #2
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 12:20 UTC
Signature
✔ signed by Andy McCall
Hash
736f6813543dc894c86ff87bc185502b4bd254ca023bd66af8bb869134e08c81
Depends on
18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 6ae41081ccd3 93be9ce4653e d23464c0ac60
Depended on by
2abea30809dd 107eaba52c9d 5d90e0e045e2 8933faff2043 745d225de344 a3c8288b0141 cc19697f8871 2bfe5ef66b0c 92574625ee9a 65be9f9c3074 1edac4b68c6d a0d4bbfbedd1 c79a1111dd56 75d611ab99d1 9991530b4bee
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
18 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/hub/src/db/flow.rs +9 −0
…
315 unchanged lines
316 316
    }
317 317
    Ok(out)
318 318
}
319 +
320 +
/// The newest run of exactly this state (heads joined with `+`, sorted).
321 +
pub async fn latest_for_heads(pool: &Pool, repo_id: i64, heads: &str) -> sqlx::Result<Option<Run>> {
322 +
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND heads = ? ORDER BY id DESC LIMIT 1")
323 +
        .bind(repo_id)
324 +
        .bind(heads)
325 +
        .fetch_optional(pool)
326 +
        .await
327 +
}
modified crates/hub/src/repos/view.rs +16 −0
…
632 unchanged lines
633 633
    })
634 634
}
635 635
636 +
/// A file's name and bytes, and nothing else (for raw responses).
637 +
pub fn raw(repo: &Repo, cache: &StateCache, target: &RefTarget, path: &str) -> Result<(String, Vec<u8>)> {
638 +
    let loaded = cache.load(repo, &target.heads)?;
639 +
    let path = path.trim_matches('/');
640 +
    let Some(f) = loaded.snapshot.files.get(path) else { return Err(RepoError::NotFound) };
641 +
    let bytes = match (&f.kind, &f.content) {
642 +
        (NodeKind::Dir, _) | (_, FileContent::Dir) => return Err(RepoError::NotFound),
643 +
        (_, FileContent::Text { bytes, .. }) => bytes.clone(),
644 +
        (_, FileContent::Opaque { blobs }) => match blobs.first() {
645 +
            Some(b) => repo.read_blob(b)?,
646 +
            None => Vec::new(),
647 +
        },
648 +
    };
649 +
    Ok((path.rsplit('/').next().unwrap_or(path).to_string(), bytes))
650 +
}
651 +
636 652
pub fn is_binary(bytes: &[u8]) -> bool {
637 653
    let head = &bytes[..bytes.len().min(8192)];
638 654
    head.contains(&0)
…
479 unchanged lines
modified crates/hub/src/web/flow.rs +129 −0
…
222 unchanged lines
223 223
    }
224 224
    Ok(Json(serde_json::json!({ "status": run.status, "live": run.live(), "jobs": out })).into_response())
225 225
}
226 +
227 +
// ------------------------------------------------------------ state stamps
228 +
229 +
/// In rendered Markdown, the image path that is the viewed state's stamp.
230 +
pub const STAMP_IMAGE: &str = ".flow/stamp";
231 +
/// In rendered Markdown, the link path to the viewed state's run.
232 +
pub const RUN_LINK: &str = ".flow/run";
233 +
234 +
/// Resolve a ref to its state address, and whether the ref can move.
235 +
async fn state_of(app: &Shared, owner: &str, name: &str, r: &str) -> Result<(String, bool), crate::repos::RepoError> {
236 +
    let repo = crate::repos::RepoName::new(owner, name).ok_or(crate::repos::RepoError::NotFound)?;
237 +
    let spec = r.to_string();
238 +
    app.repos
239 +
        .read(&repo, move |repo, cache| {
240 +
            let t = crate::repos::view::resolve(repo, cache, &spec)?;
241 +
            let moves = matches!(t.kind, crate::repos::view::RefKind::Bookmark | crate::repos::view::RefKind::Tag);
242 +
            Ok((t.permalink().unwrap_or_default(), moves))
243 +
        })
244 +
        .await
245 +
}
246 +
247 +
/// `GET /{owner}/{name}/flow/stamp/{ref}` (hub spec §13.2).
248 +
pub async fn stamp(
249 +
    State(app): State<Shared>,
250 +
    viewer: Viewer,
251 +
    Path((owner, name, r)): Path<(String, String, String)>,
252 +
) -> Result<Response, Error> {
253 +
    let shell = Shell::new(&app, "", &viewer);
254 +
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
255 +
    let (heads, moves) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
256 +
    if moves && !heads.is_empty() {
257 +
        let mut res = axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{heads}")).into_response();
258 +
        res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache"));
259 +
        return Ok(res);
260 +
    }
261 +
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
262 +
    let jobs = match &run {
263 +
        Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
264 +
        None => vec![],
265 +
    };
266 +
    let svg = stamp_svg(&heads, run.as_ref(), &jobs);
267 +
    let cache = if run.as_ref().is_some_and(|r| !r.live()) { "public, max-age=3600" } else { "public, max-age=60" };
268 +
    Ok((
269 +
        [
270 +
            (axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
271 +
            (axum::http::header::CACHE_CONTROL, cache),
272 +
            (axum::http::header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'"),
273 +
        ],
274 +
        svg,
275 +
    )
276 +
        .into_response())
277 +
}
278 +
279 +
/// `GET /{owner}/{name}/flow/at/{ref}`: the run of the ref's state, or the Flow tab.
280 +
pub async fn at(
281 +
    State(app): State<Shared>,
282 +
    viewer: Viewer,
283 +
    Path((owner, name, r)): Path<(String, String, String)>,
284 +
) -> Result<Response, Error> {
285 +
    let shell = Shell::new(&app, "", &viewer);
286 +
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
287 +
    let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
288 +
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289 +
    let to = match run {
290 +
        Some(r) => format!("/{owner}/{name}/flow/{}", r.id),
291 +
        None => format!("/{owner}/{name}/flow"),
292 +
    };
293 +
    Ok(axum::response::Redirect::to(&to).into_response())
294 +
}
295 +
296 +
fn xml(s: &str) -> String {
297 +
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
298 +
}
299 +
300 +
/// The stamp: the mark, one glyph per job, the status, the state and the
301 +
/// date; the same facts machine-readable in `<metadata>`. Fixed colours,
302 +
/// because an image does not follow the page's theme.
303 +
pub fn stamp_svg(heads: &str, run: Option<&fdb::Run>, jobs: &[fdb::Job]) -> String {
304 +
    const CH: f64 = 6.6; // IBM Plex Mono at 11px
305 +
    let colour = |s: &str| match s {
306 +
        "passed" => "#5fe3a1",
307 +
        "failed" | "error" => "#ff5e7a",
308 +
        "running" => "#00e0f0",
309 +
        _ => "#8592ad",
310 +
    };
311 +
    let short: String = heads.chars().take(12).collect();
312 +
    let (status, date) = match run {
313 +
        Some(r) => (r.status.as_str(), crate::render::time::absolute(r.created_ms as u64).chars().take(10).collect::<String>()),
314 +
        None => ("no run", String::new()),
315 +
    };
316 +
    let mut ordered: Vec<&fdb::Job> = jobs.iter().collect();
317 +
    ordered.sort_by(|a, b| (a.depth, &a.name).cmp(&(b.depth, &b.name)));
318 +
    let label = "OXYGENFLOW";
319 +
    let label_w = 14.0 + label.len() as f64 * 6.2 + 8.0;
320 +
    let glyphs_w = if ordered.is_empty() { 0.0 } else { ordered.len() as f64 * 10.0 + 6.0 };
321 +
    let tail = if date.is_empty() { format!("{status} · {short}") } else { format!("{status} · {short} · {date}") };
322 +
    let right_w = 8.0 + glyphs_w + tail.chars().count() as f64 * CH + 8.0;
323 +
    let w = (label_w + right_w).ceil();
324 +
    let mut glyphs = String::new();
325 +
    for (i, j) in ordered.iter().enumerate() {
326 +
        let x = label_w + 8.0 + i as f64 * 10.0 + 4.0;
327 +
        glyphs.push_str(&format!(
328 +
            r#"<text x="{x:.1}" y="15" fill="{}" text-anchor="middle">{}</text>"#,
329 +
            colour(&j.status),
330 +
            glyph(&j.status)
331 +
        ));
332 +
    }
333 +
    let meta = serde_json::json!({
334 +
        "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(),
335 +
        "run": run.map(|r| r.id),
336 +
        "status": status,
337 +
        "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(),
338 +
    });
339 +
    let title = match run {
340 +
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.id),
341 +
        None => format!("OxygenFlow: no run for state {short}"),
342 +
    };
343 +
    format!(
344 +
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="22" viewBox="0 0 {w} 22" role="img" aria-label="{t}"><title>{t}</title><metadata>{m}</metadata><rect width="{w}" height="22" rx="4" fill="#0e1526"/><rect width="{lw:.1}" height="22" rx="4" fill="#0b3f9e"/><rect x="{lx:.1}" width="4" height="22" fill="#0b3f9e"/><circle cx="9" cy="11" r="3.2" fill="none" stroke="#00f0ff" stroke-width="1.6"/><text x="16" y="15" fill="#ffffff" font-family="Barlow, Verdana, sans-serif" font-size="10.5" font-weight="700" letter-spacing=".6">{label}</text><g font-family="IBM Plex Mono, ui-monospace, monospace" font-size="11">{glyphs}<text x="{tx:.1}" y="15" fill="{sc}">{tail}</text></g><rect x=".5" y=".5" width="{wi}" height="21" rx="3.5" fill="none" stroke="#1f2b47"/></svg>"##,
345 +
        t = xml(&title),
346 +
        m = xml(&meta.to_string()),
347 +
        lw = label_w,
348 +
        lx = label_w - 4.0,
349 +
        tx = label_w + 8.0 + glyphs_w,
350 +
        sc = colour(status),
351 +
        tail = xml(&tail),
352 +
        wi = w - 1.0,
353 +
    )
354 +
}
modified crates/hub/src/web/issues.rs +1 −1
…
17 unchanged lines
18 18
pub async fn markdown(app: &Shared, repo_id: i64, repo_url: &str, text: &str) -> String {
19 19
    let (issues, proposals) = idb::numbers(&app.db, repo_id).await.unwrap_or_default();
20 20
    let linked = render::refs::link(text, &Numbers { repo_url, issues: &issues, proposals: &proposals });
21 -
    render::markdown::render(&linked, &format!("{repo_url}/file/main/"), &format!("{repo_url}/file/main/"))
21 +
    render::markdown::render(&linked, &format!("{repo_url}/file/main/"), &format!("{repo_url}/raw/main/"))
22 22
}
23 23
24 24
/// Re-index a thread for search: title, body and every comment.
…
924 unchanged lines
modified crates/hub/src/web/mod.rs +3 −0
…
82 unchanged lines
83 83
        .route("/{owner}/{name}/tree/{r}", get(repo::tree_root))
84 84
        .route("/{owner}/{name}/tree/{r}/{*path}", get(repo::tree))
85 85
        .route("/{owner}/{name}/file/{r}/{*path}", get(repo::file))
86 +
        .route("/{owner}/{name}/raw/{r}/{*path}", get(repo::raw_file))
86 87
        .route("/{owner}/{name}/last/{r}", get(repo::last_changes))
87 88
        .route("/{owner}/{name}/changes/{r}", get(history::history))
88 89
        .route("/{owner}/{name}/change/{hash}", get(history::change))
…
32 unchanged lines
121 122
        .route("/{owner}/{name}/flow", get(flow::runs))
122 123
        .route("/{owner}/{name}/flow/{id}", get(flow::run))
123 124
        .route("/{owner}/{name}/flow/{id}/live", get(flow::live))
125 +
        .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp))
126 +
        .route("/{owner}/{name}/flow/at/{r}", get(flow::at))
124 127
        .route("/{owner}/{name}/releases", get(releases::list))
125 128
        .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create))
126 129
        .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show))
…
430 unchanged lines
modified crates/hub/src/web/repo.rs +44 −4
…
123 unchanged lines
124 124
    fn readme_html(&self, rs: &RepoShell, r: &str) -> Option<String> {
125 125
        self.tree.readme.as_ref().map(|md| {
126 126
            let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
127 -
            render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url))
127 +
            render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/raw/{r}/{dir}", rs.url))
128 128
        })
129 129
    }
130 130
}
…
34 unchanged lines
165 165
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
166 166
    let readme_html = t.readme.as_ref().map(|md| {
167 167
        let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
168 -
        render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url))
168 +
        render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/raw/{r}/{dir}", rs.url))
169 169
    });
170 170
    Ok(Page(TreePage { shell, repo: rs, t, readme_html }))
171 171
}
…
84 unchanged lines
256 256
) -> Result<Response, Error> {
257 257
    let shell = Shell::new(&app, &format!("/{owner}/{name}/file/{r}/{path}"), &viewer);
258 258
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
259 +
    if path == super::flow::RUN_LINK && !has_file(&app, &repo, &r, &path).await {
260 +
        return Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/flow/at/{r}")).into_response());
261 +
    }
259 262
    let limit = app.config.limits.render_limit.0;
260 263
    let want_blame = q.blame.is_some();
261 264
    let (spec, p) = (r.clone(), path.clone());
…
22 unchanged lines
284 287
        FileBody::Text(text) if !is_image => {
285 288
            if is_markdown && !want_source {
286 289
                let dir = f.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default();
287 -
                let base = format!("{}/file/{r}/{dir}", rs.url);
288 -
                (vec![], highlight::language(&f.path), Some(render::markdown::render(text, &base, &base)))
290 +
                let (base, raw) = (format!("{}/file/{r}/{dir}", rs.url), format!("{}/raw/{r}/{dir}", rs.url));
291 +
                (vec![], highlight::language(&f.path), Some(render::markdown::render(text, &base, &raw)))
289 292
            } else {
290 293
                (highlight::lines(&f.path, text), highlight::language(&f.path), None)
291 294
            }
…
2 unchanged lines
294 297
    };
295 298
    let permalink = f.target.permalink();
296 299
    Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame, permalink }).into_response())
300 +
}
301 +
302 +
/// Whether `path` is a file in the state `r` names.
303 +
async fn has_file(app: &Shared, repo: &crate::repos::RepoName, r: &str, path: &str) -> bool {
304 +
    let (spec, p) = (r.to_string(), path.to_string());
305 +
    app.repos
306 +
        .read(repo, move |repo, cache| {
307 +
            let target = view::resolve(repo, cache, &spec)?;
308 +
            Ok(cache.load(repo, &target.heads)?.snapshot.files.get(&p).is_some_and(|f| f.kind != oxy_core::objects::NodeKind::Dir))
309 +
        })
310 +
        .await
311 +
        .unwrap_or(false)
312 +
}
313 +
314 +
/// `GET /{owner}/{name}/raw/{ref}/{path}`: a file's bytes (hub spec §6.3),
315 +
/// where relative images in rendered Markdown point. `.flow/stamp` is the
316 +
/// state stamp of `{ref}` unless a real file is there (§13.2).
317 +
pub async fn raw_file(
318 +
    State(app): State<Shared>,
319 +
    viewer: Viewer,
320 +
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
321 +
) -> Result<Response, Error> {
322 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/raw/{r}/{path}"), &viewer);
323 +
    let (repo, _row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
324 +
    if path == super::flow::STAMP_IMAGE && !has_file(&app, &repo, &r, &path).await {
325 +
        return Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{r}")).into_response());
326 +
    }
327 +
    let (spec, p) = (r.clone(), path.clone());
328 +
    let (file_name, bytes) = app
329 +
        .repos
330 +
        .read(&repo, move |repo, cache| {
331 +
            let target = view::resolve(repo, cache, &spec)?;
332 +
            view::raw(repo, cache, &target, &p)
333 +
        })
334 +
        .await
335 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
336 +
    Ok(super::raw(bytes, &file_name, true))
297 337
}
298 338
299 339
#[derive(Deserialize)]
…
143 unchanged lines
modified crates/hub/tests/flow.rs +53 −0
…
70 unchanged lines
71 71
    (status, String::from_utf8_lossy(&bytes).into_owned())
72 72
}
73 73
74 +
/// A GET's status, `Location` and `Content-Type`.
75 +
async fn head(app: &Shared, path: &str) -> (StatusCode, String, String) {
76 +
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
77 +
    let h = |k: header::HeaderName| res.headers().get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
78 +
    (res.status(), h(header::LOCATION), h(header::CONTENT_TYPE))
79 +
}
80 +
74 81
async fn claim(app: &Shared) -> Option<serde_json::Value> {
75 82
    let (st, body) =
76 83
        call(app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(r#"{"runner":"test-1","runtimes":["microvm"]}"#.into())).await;
…
251 unchanged lines
328 335
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.id), None, None).await;
329 336
    assert!(page.contains("The pipeline is not valid"));
330 337
338 +
    // State stamps (hub spec §13.2) and images in READMEs.
339 +
    write(work.path(), "README.md", "# Demo\n\n[![flow](.flow/stamp)](.flow/run)\n\n![logo](img/logo.png)\n");
340 +
    std::fs::create_dir_all(work.path().join("img")).unwrap();
341 +
    std::fs::write(work.path().join("img/logo.png"), b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x06\0\0\0\x1f\x15\xc4\x89")
342 +
        .unwrap();
343 +
    c.snapshot("test").unwrap();
344 +
    c.commit("A README with a stamp", &[]).unwrap();
345 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
346 +
    let mut fourth = None;
347 +
    for _ in 0..100 {
348 +
        let r = runs_of(&h.app, "andy", "demo").await;
349 +
        if r.len() == 4 {
350 +
            fourth = Some(r[0].clone());
351 +
            break;
352 +
        }
353 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
354 +
    }
355 +
    let fourth = fourth.expect("a fourth run");
356 +
    let (_, overview) = call(&h.app, "GET", "/andy/demo", None, None).await;
357 +
    assert!(overview.contains("src=\"/andy/demo/raw/main/.flow/stamp\""), "{overview}");
358 +
    assert!(overview.contains("href=\"/andy/demo/file/main/.flow/run\""));
359 +
    assert!(overview.contains("src=\"/andy/demo/raw/main/img/logo.png\""), "images use the raw route");
360 +
    let (st, _, ct) = head(&h.app, "/andy/demo/raw/main/img/logo.png").await;
361 +
    assert_eq!((st, ct.as_str()), (StatusCode::OK, "image/png"));
362 +
    let (st, loc, _) = head(&h.app, "/andy/demo/raw/main/.flow/stamp").await;
363 +
    assert!(st.is_redirection() && loc == "/andy/demo/flow/stamp/main", "{st} {loc}");
364 +
    let (st, loc, _) = head(&h.app, "/andy/demo/flow/stamp/main").await;
365 +
    assert!(st.is_redirection() && loc == format!("/andy/demo/flow/stamp/{}", fourth.heads), "a bookmark's stamp names its state: {loc}");
366 +
    let (st, _, ct) = head(&h.app, &loc).await;
367 +
    assert_eq!((st, ct.as_str()), (StatusCode::OK, "image/svg+xml; charset=utf-8"));
368 +
    let (_, svg) = call(&h.app, "GET", &loc, None, None).await;
369 +
    assert!(svg.starts_with("<svg") && svg.contains("error ·") && svg.contains(&fourth.heads[..12]), "{svg}");
370 +
    let (_, svg1) = call(&h.app, "GET", &format!("/andy/demo/flow/stamp/{}", run.heads), None, None).await;
371 +
    assert!(svg1.contains("failed ·") && svg1.contains(&format!("run #{} failed", run.id)), "an older state keeps its own result: {svg1}");
372 +
    assert!(svg1.contains(&build.receipt_hash), "receipt hashes in the metadata");
373 +
    assert_eq!(svg1.matches("text-anchor=\"middle\"").count(), 3, "one glyph per job");
374 +
    let (st, loc, _) = head(&h.app, "/andy/demo/file/main/.flow/run").await;
375 +
    assert!(st.is_redirection() && loc == "/andy/demo/flow/at/main", "{loc}");
376 +
    let (st, loc, _) = head(&h.app, "/andy/demo/flow/at/main").await;
377 +
    assert!(st.is_redirection() && loc == format!("/andy/demo/flow/{}", fourth.id), "{loc}");
378 +
331 379
    // A repository outside [flow] repos starts nothing.
332 380
    let work2 = tempfile::tempdir().unwrap();
333 381
    let c2 = client(&format!("{}/other/plain", h.base), work2.path());
…
6 unchanged lines
340 388
    assert!(db::flow::runs(&h.app.db, row.id, 10, None).await.unwrap().is_empty());
341 389
    let (_, page) = call(&h.app, "GET", "/other/plain/flow", None, None).await;
342 390
    assert!(page.contains("not enabled for this repository"));
391 +
    let (_, loc, _) = head(&h.app, "/other/plain/flow/stamp/main").await;
392 +
    let (_, svg) = call(&h.app, "GET", &loc, None, None).await;
393 +
    assert!(svg.contains("no run ·"), "{svg}");
394 +
    let (_, loc, _) = head(&h.app, "/other/plain/flow/at/main").await;
395 +
    assert_eq!(loc, "/other/plain/flow");
343 396
}
modified docs/spec.md +7 −0
…
231 unchanged lines
232 232
| `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source |
233 233
| `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag, change or state (§6.5). A `{ref}` is a bookmark name, a tag name, a change hash or unique prefix, or a state: full change hashes joined with `+` (`a1…+b2…`), the state those heads make, which is how a state with several heads is addressed permanently |
234 234
| `/{owner}/{name}/file/{ref}/{path}` | File view, `?plain=1` raw, `?blame=1` blame (§6.6) |
235 +
| `/{owner}/{name}/raw/{ref}/{path}` | A file's bytes, as `?plain=1` serves them (images inline with a sniffed type, anything else as an attachment); relative images in rendered Markdown point here |
235 236
| `/{owner}/{name}/changes/{ref}` | History (§6.7) |
236 237
| `/{owner}/{name}/change/{hash}` | Change page (§6.8) |
237 238
| `/{owner}/{name}/compare/{a}...{b}` | Changes in `b` not in `a`, and the diff between the two states |
…
164 unchanged lines
402 403
- **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories.
403 404
- **Checks.** Each job is also the check `flow/<job>` on the run's state (§13.1), so proposals, required checks and the editor's status bar see it.
404 405
- **Pages.** A **Flow** tab lists runs (status, bookmark, state, pusher, when, duration). A run page draws the job graph as SVG, with columns by dependency depth and status by colour and glyph, and each job's log below (folded, the failed ones open); it refreshes while the run is live. Readers of the repository can see both.
406 +
407 +
**State stamps** replace CI badges, which are mutable images claiming that a branch passes, name no state and cannot be checked. A stamp belongs to a state:
408 +
409 +
- `/{owner}/{name}/flow/stamp/{ref}` is an SVG. For a state address (heads joined with `+`) or a change, it shows the newest run of exactly that state: one glyph per job in dependency order, the run's status, the state's short hash and the run's date. It shows "no run" when there is none. The same data is machine-readable in the SVG's `<metadata>`: heads, run number, status, and each job's status and receipt hash. For a bookmark or tag, it redirects to the stamp of the ref's current state, so even the image names what was checked. A finished run's stamp may be cached for an hour; any other stamp for a minute.
410 +
- In rendered Markdown, the relative paths `.flow/stamp` (as an image) and `.flow/run` (as a link) resolve against the state being viewed. The first is served by `/raw/{ref}/.flow/stamp`, the second by `/file/{ref}/.flow/run` (the run page, or the Flow tab when that state has no run). So `[![flow](.flow/stamp)](.flow/run)` in a README shows how the viewed state did: browsing a tag shows the tag's result, not the bookmark's. A real file at either path takes precedence.
411 +
- Later, stamps also say which runner key sealed the result (once runner seals exist) and which jobs reused an earlier receipt.
405 412
406 413
The older runner draft (`docs/runner.md`) is superseded by OxygenFlow; its hub-side ideas (environments, approvals for outsider proposals, caches, artifacts) carry over as later Flow milestones.
407 414
…
147 unchanged lines