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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
|
| 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/run)\n\n\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/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 |