OXYGENHUB

Flow job reports and timings over the API, oh run report and oh report; 0.9.14 (closes #348) fac6853c4e46

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7036819b6ece
When
· 2026-10-28 21:16 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
fac6853c4e46a6c9edfcb845d0fb6480cfa4f57b89ab374db1a1dd264643d689
Depends on
166f6e9bf4f1 1edac4b68c6d 20b8fe85888b 3d086bc3d69b 40dd3f86379a 42fa95540468 50d93fa443dc 5c6e92fd8d71 617c2840446c 67040b158ae2 8aa5ef384ef1 90326c9601f9 93be9ce4653e 95b525b0b977 9bbca80f777a a36bab6b9ffc b1860d6090ec b5668108487f ba0a55bb5b3c bf1ab3458db1 c8df33a6829f d23464c0ac60 dc59d1b56c3d df71c7892d27 e8fa53694f68
Depended on by
306aec1839aa 8f1d76f4169a c26267b3aba6
In bookmarks
main
In tags
v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
43 atoms, 0 tree operations, 13 files

Browse the tree at this change

modified CHANGELOG.md +5 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## 0.9.14 (2026-10-28)
6 +
7 +
- **Read a Flow job's report over the API and with `oh` (#348).** A report a job published could only be read on its web page. Now `GET /api/v1/repos/OWNER/NAME/flow/runs/N/jobs/JOB/report` gives it with its metrics, context and files, `…/report/PATH` one file as published (`data/frames.csv`), `…/jobs/JOB/reports` every report the job published, `GET …/reports` the newest run of each report (`?bookmark=`), and `GET …/reports/NAME` one run by `?id=` (the id the web page carries) or the newest on a bookmark, with `…/files/PATH`. Files come sandboxed, so a page in one runs nothing on the hub; errors name the jobs, reports and files there are. `oh run report RUN JOB` prints its `report.toml`, `--list` what it holds, `--file PATH [-o FILE]` one file; `oh report list` and `oh report show NAME` do the same by name.
8 +
- **A job's timings over the API (#348).** A run's jobs now say when each was queued and claimed, the phases its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; `oh run view N --timings` prints them under each job ("waited 12.0 s · image 850 ms · run 2 min 5 s · peak disk 2.1 GiB, memory 900 MiB").
9 +
5 10
## 0.9.13 (2026-10-28)
6 11
7 12
- **An assistant drafts a new repository's starting files (#347).** Creating a repository can come with a description of what it is for: `POST /api/v1/repos/OWNER/NAME/starter {"description"}` asks the owner's assistant (the site's, or the organisation's) for a small working starting point (a README, the build files, a source layout and a first test), checks what comes back (relative paths, text, at most 40 files and 256 KiB), and commits it to the empty repository's default bookmark as the person who asked, signed by the hub as their delegate, the change naming the assistant and what was asked. It runs in the background, since a model may take longer than a proxied request may last; `GET …/starter` says how it went, and `GET /api/v1/user/starter?owner=X` which assistants may be asked. Never over files that are there. The Android app's New repository uses it.
…
406 unchanged lines
modified Cargo.lock +3 −3
…
1459 unchanged lines
1460 1460
1461 1461
[[package]]
1462 1462
name = "hub-api"
1463 -
version = "0.9.13"
1463 +
version = "0.9.14"
1464 1464
dependencies = [
1465 1465
 "schemars",
1466 1466
 "serde",
…
797 unchanged lines
2264 2264
2265 2265
[[package]]
2266 2266
name = "oh"
2267 -
version = "0.9.13"
2267 +
version = "0.9.14"
2268 2268
dependencies = [
2269 2269
 "anyhow",
2270 2270
 "axum",
…
86 unchanged lines
2357 2357
2358 2358
[[package]]
2359 2359
name = "oxygenhub"
2360 -
version = "0.9.13"
2360 +
version = "0.9.14"
2361 2361
dependencies = [
2362 2362
 "ammonia",
2363 2363
 "anyhow",
…
2513 unchanged lines
modified Cargo.toml +1 −1
…
6 unchanged lines
7 7
resolver = "2"
8 8
9 9
[workspace.package]
10 -
version = "0.9.13"
10 +
version = "0.9.14"
11 11
edition = "2021"
12 12
license = "MIT OR Apache-2.0"
13 13
authors = ["Andy McCall <[email protected]>"]
…
60 unchanged lines
modified crates/hub-api/src/lib.rs +80 −0
…
605 unchanged lines
606 606
    /// empty for a job with no gate.
607 607
    #[serde(default, skip_serializing_if = "String::is_empty")]
608 608
    pub approval: String,
609 +
    /// When it entered the queue (made, or run again), and when a runner
610 +
    /// claimed it: its wait is `started_ms - queued_ms` (oxygenhub#348).
611 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
612 +
    pub queued_ms: Option<i64>,
613 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
614 +
    pub ready_ms: Option<i64>,
615 +
    /// Where its time went, as its runner reported (oxygenflow#35):
616 +
    /// `image`, `prepare`, `run` and `finish`, in that order.
617 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
618 +
    pub phases: Vec<FlowPhase>,
619 +
    /// The most disk and memory it took, as its runner reported, in MiB.
620 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
621 +
    pub peak_disk_mib: Option<i64>,
622 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
623 +
    pub peak_memory_mib: Option<i64>,
624 +
}
625 +
626 +
/// A phase of a job and its milliseconds.
627 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
628 +
pub struct FlowPhase {
629 +
    pub name: String,
630 +
    pub ms: u64,
631 +
}
632 +
633 +
/// A report: what a Flow job published (OxygenFlow `output { report … }`)
634 +
/// or an upload, as docs/reports.md describes (oxygenhub#348).
635 +
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
636 +
pub struct Report {
637 +
    /// Its id, as the web page's `?id=` carries it.
638 +
    pub id: i64,
639 +
    /// The name the pipeline gave it (`report "frame-timing"`).
640 +
    pub name: String,
641 +
    /// `coverage`, `tests`, `performance` or `custom`.
642 +
    pub kind: String,
643 +
    pub title: String,
644 +
    pub summary: String,
645 +
    pub metrics: Vec<ReportMetric>,
646 +
    /// Its `[context]` table.
647 +
    pub context: serde_json::Value,
648 +
    /// The run and job that published it; none for an upload.
649 +
    pub run: Option<i64>,
650 +
    pub job: String,
651 +
    pub bookmark: String,
652 +
    /// The state it measured.
653 +
    pub state: String,
654 +
    /// How many files it has and their size together.
655 +
    pub files: i64,
656 +
    pub bytes: i64,
657 +
    pub created_ms: i64,
658 +
    /// When its files expired under retention (docs/reports.md §3.3): its
659 +
    /// metrics stay, its files are gone.
660 +
    pub files_expired_ms: Option<i64>,
661 +
    /// Its page on the hub.
662 +
    pub url: String,
663 +
    /// Its files, filled when one report is asked for.
664 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
665 +
    pub manifest: Vec<ReportFile>,
666 +
}
667 +
668 +
/// A metric of a report.
669 +
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
670 +
pub struct ReportMetric {
671 +
    pub key: String,
672 +
    pub value: f64,
673 +
    pub unit: String,
674 +
    /// `lower`, `higher`, or empty.
675 +
    pub better: String,
676 +
    pub fail_above: Option<f64>,
677 +
    pub fail_below: Option<f64>,
678 +
}
679 +
680 +
/// A file of a report.
681 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
682 +
pub struct ReportFile {
683 +
    pub path: String,
684 +
    pub size: i64,
609 685
}
610 686
611 687
/// A line of a job's log.
…
818 unchanged lines
1430 1506
        ("FlowRun", schemars::schema_for!(FlowRun)),
1431 1507
        ("FlowJob", schemars::schema_for!(FlowJob)),
1432 1508
        ("FlowLogLine", schemars::schema_for!(FlowLogLine)),
1509 +
        ("FlowPhase", schemars::schema_for!(FlowPhase)),
1510 +
        ("Report", schemars::schema_for!(Report)),
1511 +
        ("ReportMetric", schemars::schema_for!(ReportMetric)),
1512 +
        ("ReportFile", schemars::schema_for!(ReportFile)),
1433 1513
        ("AssetBuild", schemars::schema_for!(AssetBuild)),
1434 1514
        ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)),
1435 1515
        ("NewRelease", schemars::schema_for!(NewRelease)),
…
301 unchanged lines
modified crates/hub/src/api/mod.rs +11 −0
…
195 unchanged lines
196 196
        .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run))
197 197
        .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run))
198 198
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log))
199 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reports", get(reports::job_reports))
200 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report", get(reports::job_report_one))
201 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report/{*path}", get(reports::job_report_file))
202 +
        .route("/repos/{owner}/{name}/reports", get(reports::latest_reports))
203 +
        .route("/repos/{owner}/{name}/reports/{report}", get(reports::named_report_one))
204 +
        .route("/repos/{owner}/{name}/reports/{report}/files/{*path}", get(reports::named_report_file))
199 205
        .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run))
200 206
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job))
201 207
        .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run))
…
2475 unchanged lines
2677 2683
        jobs: jobs
2678 2684
            .into_iter()
2679 2685
            .map(|j| FlowJob {
2686 +
                phases: j.phase_list().into_iter().map(|(name, ms)| hub_api::FlowPhase { name, ms }).collect(),
2687 +
                queued_ms: j.queued_ms,
2688 +
                ready_ms: j.ready_ms,
2689 +
                peak_disk_mib: j.peak_disk_mib,
2690 +
                peak_memory_mib: j.peak_memory_mib,
2680 2691
                depends_on: j.depends_on(),
2681 2692
                name: j.name,
2682 2693
                status: j.status,
…
778 unchanged lines
modified crates/hub/src/api/reports.rs +231 −5
1 -
//! Coverage (oxygenhub#113) and test reports (#114) over the API: what the
2 -
//! Coverage and Tests tabs show, for whoever may read the repository.
1 +
//! Coverage (oxygenhub#113), test reports (#114) and the reports Flow jobs
2 +
//! publish (#348) over the API: what the Coverage, Tests and Reports tabs
3 +
//! show, for whoever may read the repository.
3 4
4 5
use super::{caller, not_found, reach, ApiError};
5 6
use crate::auth::Access;
6 -
use crate::{coverage, junit, Shared};
7 +
use crate::{coverage, junit, reports, Shared};
7 8
use axum::extract::{Path, Query, State};
8 -
use axum::http::HeaderMap;
9 +
use axum::http::{header, HeaderMap, HeaderValue};
10 +
use axum::response::{IntoResponse, Response};
9 11
use axum::Json;
10 -
use hub_api::{CoverageDetail, CoverageFile, CoverageReport, TestCase, TestReport, TestReportDetail};
12 +
use hub_api::{CoverageDetail, CoverageFile, CoverageReport, Report, ReportFile, ReportMetric, TestCase, TestReport, TestReportDetail};
11 13
use serde::Deserialize;
12 14
13 15
#[derive(Deserialize, Default)]
…
227 unchanged lines
241 243
        .collect();
242 244
    Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases }))
243 245
}
246 +
247 +
#[derive(Deserialize, Default)]
248 +
pub struct ReportQuery {
249 +
    /// Which of a job's reports, when it published more than one.
250 +
    name: Option<String>,
251 +
    /// One run of a report, by its id (the web page's `?id=`).
252 +
    id: Option<i64>,
253 +
    /// The bookmark whose newest run of a report to take (default: the
254 +
    /// repository's default, else wherever it ran last).
255 +
    bookmark: Option<String>,
256 +
}
257 +
258 +
/// A kept report as the API shows it, with its metrics; with its files
259 +
/// when `files` says so.
260 +
async fn report_of(app: &Shared, base: &str, r: &reports::Report, files: bool) -> Result<Report, ApiError> {
261 +
    let metrics = reports::metrics(&app.db, r.id)
262 +
        .await?
263 +
        .into_iter()
264 +
        .map(|m| ReportMetric {
265 +
            key: m.key,
266 +
            value: m.value,
267 +
            unit: m.unit,
268 +
            better: m.better,
269 +
            fail_above: m.fail_above,
270 +
            fail_below: m.fail_below,
271 +
        })
272 +
        .collect();
273 +
    let manifest = if files {
274 +
        reports::files_of(&app.db, r.id).await?.into_iter().map(|(path, size)| ReportFile { path, size }).collect()
275 +
    } else {
276 +
        vec![]
277 +
    };
278 +
    Ok(Report {
279 +
        id: r.id,
280 +
        name: r.name.clone(),
281 +
        kind: r.kind.clone(),
282 +
        title: r.title.clone(),
283 +
        summary: r.summary.clone(),
284 +
        metrics,
285 +
        context: serde_json::from_str(&r.context).unwrap_or_default(),
286 +
        run: r.run_number,
287 +
        job: r.job.clone(),
288 +
        bookmark: r.bookmark.clone(),
289 +
        state: r.state.clone(),
290 +
        files: r.files,
291 +
        bytes: r.bytes,
292 +
        created_ms: r.created_ms,
293 +
        files_expired_ms: r.files_expired_ms,
294 +
        url: format!("{}{base}/reports/{}?id={}", app.config.base_url().trim_end_matches('/'), r.name, r.id),
295 +
        manifest,
296 +
    })
297 +
}
298 +
299 +
/// The report a job of run `number` published: the only one, or the one
300 +
/// `name` names. The errors say what there is.
301 +
async fn job_report(app: &Shared, repo_id: i64, number: i64, job: &str, name: Option<&str>) -> Result<reports::Report, ApiError> {
302 +
    let run = crate::db::flow::run(&app.db, repo_id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
303 +
    let jobs = crate::db::flow::jobs(&app.db, run.id).await?;
304 +
    let Some(j) = jobs.iter().find(|j| j.name == job) else {
305 +
        let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect();
306 +
        return Err(not_found(&format!("run #{number} has no job {job}; its jobs: {}", names.join(", "))));
307 +
    };
308 +
    let mut list = reports::of_job(&app.db, j.id).await?;
309 +
    let names = || list.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
310 +
    match name {
311 +
        Some(n) => match list.iter().position(|r| r.name == n) {
312 +
            Some(i) => Ok(list.swap_remove(i)),
313 +
            None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
314 +
            None => Err(not_found(&format!("job {job} of run #{number} published no report {n}; it published {}", names()))),
315 +
        },
316 +
        None if list.len() == 1 => Ok(list.remove(0)),
317 +
        None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
318 +
        None => Err(ApiError(
319 +
            axum::http::StatusCode::BAD_REQUEST,
320 +
            format!("job {job} of run #{number} published {} reports ({}): say which with ?name=", list.len(), names()),
321 +
        )),
322 +
    }
323 +
}
324 +
325 +
/// Report `name`'s run `id`, or its newest on `bookmark` (default: the
326 +
/// repository's default bookmark, else wherever it ran last), as the
327 +
/// report's web page picks it.
328 +
async fn named_report(app: &Shared, row: &crate::db::repos::RepoRow, name: &str, q: &ReportQuery) -> Result<reports::Report, ApiError> {
329 +
    if let Some(id) = q.id {
330 +
        return reports::get(&app.db, row.id, id)
331 +
            .await?
332 +
            .filter(|r| r.name == name)
333 +
            .ok_or_else(|| not_found(&format!("report {name} has no run with id {id}")));
334 +
    }
335 +
    let bookmarks = reports::bookmarks_of(&app.db, row.id, name).await?;
336 +
    if bookmarks.is_empty() {
337 +
        return Err(not_found(&format!("no report {name} in {}/{}", row.owner, row.name)));
338 +
    }
339 +
    let bookmark = match q.bookmark.clone().filter(|b| !b.is_empty()) {
340 +
        Some(b) if bookmarks.contains(&b) => b,
341 +
        Some(b) => return Err(not_found(&format!("report {name} never ran on {b}; it ran on {}", bookmarks.join(", ")))),
342 +
        None => bookmarks.iter().find(|b| **b == row.default_bookmark).unwrap_or(&bookmarks[0]).clone(),
343 +
    };
344 +
    reports::history(&app.db, row.id, name, &bookmark, 1).await?.into_iter().next().ok_or_else(|| not_found("no such report"))
345 +
}
346 +
347 +
/// A report's file as it was published. It is served as a download would
348 +
/// be: under a sandbox that runs nothing, so a page in it can do nothing
349 +
/// on the hub's own origin.
350 +
async fn report_file(app: &Shared, repo: &crate::repos::RepoName, r: &reports::Report, path: &str) -> Result<Response, ApiError> {
351 +
    if let Some(when) = r.files_expired_ms {
352 +
        return Err(ApiError(
353 +
            axum::http::StatusCode::GONE,
354 +
            format!("report {} (id {}) lost its files under retention at {when} ms; its metrics stay", r.name, r.id),
355 +
        ));
356 +
    }
357 +
    let Some((blob, _)) = reports::file(&app.db, r.id, path).await? else {
358 +
        let files: Vec<String> = reports::files_of(&app.db, r.id).await?.into_iter().map(|(p, _)| p).take(20).collect();
359 +
        return Err(not_found(&format!("report {} has no file {path}; it has {}", r.name, files.join(", "))));
360 +
    };
361 +
    let oref = oxy_core::hash::ObjectRef::from_hex(&blob).ok_or_else(|| not_found("the file's blob is unreadable"))?;
362 +
    let bytes = app
363 +
        .repos
364 +
        .read(repo, move |r, _| Ok(r.read_blob(&oref)?))
365 +
        .await
366 +
        .map_err(|e| ApiError(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("the file could not be read: {e}")))?;
367 +
    let mut res = (axum::http::StatusCode::OK, bytes).into_response();
368 +
    let h = res.headers_mut();
369 +
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(crate::web::reports::content_type(path)));
370 +
    h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("sandbox; default-src 'none'"));
371 +
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
372 +
    Ok(res)
373 +
}
374 +
375 +
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/reports`: the reports
376 +
/// a job published (oxygenhub#348).
377 +
pub async fn job_reports(
378 +
    State(app): State<Shared>,
379 +
    headers: HeaderMap,
380 +
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
381 +
) -> Result<Json<Vec<Report>>, ApiError> {
382 +
    let c = caller(&app, &headers).await?;
383 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
384 +
    let run = crate::db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
385 +
    let j = crate::db::flow::jobs(&app.db, run.id)
386 +
        .await?
387 +
        .into_iter()
388 +
        .find(|j| j.name == job)
389 +
        .ok_or_else(|| not_found(&format!("run #{number} has no job {job}")))?;
390 +
    let base = format!("/{owner}/{name}");
391 +
    let mut out = vec![];
392 +
    for r in reports::of_job(&app.db, j.id).await? {
393 +
        out.push(report_of(&app, &base, &r, false).await?);
394 +
    }
395 +
    Ok(Json(out))
396 +
}
397 +
398 +
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report[?name=]`: the
399 +
/// report a job published, with its files (oxygenhub#348).
400 +
pub async fn job_report_one(
401 +
    State(app): State<Shared>,
402 +
    headers: HeaderMap,
403 +
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
404 +
    Query(q): Query<ReportQuery>,
405 +
) -> Result<Json<Report>, ApiError> {
406 +
    let c = caller(&app, &headers).await?;
407 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
408 +
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
409 +
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
410 +
}
411 +
412 +
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report/{*path}[?name=]`:
413 +
/// one file of it, as published.
414 +
pub async fn job_report_file(
415 +
    State(app): State<Shared>,
416 +
    headers: HeaderMap,
417 +
    Path((owner, name, number, job, path)): Path<(String, String, i64, String, String)>,
418 +
    Query(q): Query<ReportQuery>,
419 +
) -> Result<Response, ApiError> {
420 +
    let c = caller(&app, &headers).await?;
421 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
422 +
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
423 +
    report_file(&app, &repo, &r, &path).await
424 +
}
425 +
426 +
/// `GET /repos/{o}/{n}/reports[?bookmark=]`: the newest run of each report,
427 +
/// on `bookmark` when given, newest first (oxygenhub#348).
428 +
pub async fn latest_reports(
429 +
    State(app): State<Shared>,
430 +
    headers: HeaderMap,
431 +
    Path((owner, name)): Path<(String, String)>,
432 +
    Query(q): Query<ReportQuery>,
433 +
) -> Result<Json<Vec<Report>>, ApiError> {
434 +
    let c = caller(&app, &headers).await?;
435 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
436 +
    let base = format!("/{owner}/{name}");
437 +
    let mut out = vec![];
438 +
    for r in reports::latest(&app.db, row.id, q.bookmark.as_deref().filter(|b| !b.is_empty())).await? {
439 +
        out.push(report_of(&app, &base, &r, false).await?);
440 +
    }
441 +
    Ok(Json(out))
442 +
}
443 +
444 +
/// `GET /repos/{o}/{n}/reports/{report}[?id=|?bookmark=]`: one run of a
445 +
/// report with its files: by id, else the newest on the bookmark.
446 +
pub async fn named_report_one(
447 +
    State(app): State<Shared>,
448 +
    headers: HeaderMap,
449 +
    Path((owner, name, report)): Path<(String, String, String)>,
450 +
    Query(q): Query<ReportQuery>,
451 +
) -> Result<Json<Report>, ApiError> {
452 +
    let c = caller(&app, &headers).await?;
453 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
454 +
    let r = named_report(&app, &row, &report, &q).await?;
455 +
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
456 +
}
457 +
458 +
/// `GET /repos/{o}/{n}/reports/{report}/files/{*path}[?id=|?bookmark=]`.
459 +
pub async fn named_report_file(
460 +
    State(app): State<Shared>,
461 +
    headers: HeaderMap,
462 +
    Path((owner, name, report, path)): Path<(String, String, String, String)>,
463 +
    Query(q): Query<ReportQuery>,
464 +
) -> Result<Response, ApiError> {
465 +
    let c = caller(&app, &headers).await?;
466 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
467 +
    let r = named_report(&app, &row, &report, &q).await?;
468 +
    report_file(&app, &repo, &r, &path).await
469 +
}
modified crates/hub/src/reports.rs +10 −0
…
411 unchanged lines
412 412
    sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = $1 AND path = $2").bind(id).bind(path).fetch_optional(pool).await
413 413
}
414 414
415 +
/// Report `id`'s files, by path, with their sizes.
416 +
pub async fn files_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<(String, i64)>> {
417 +
    sqlx::query_as("SELECT path, size FROM report_files WHERE report_id = $1 ORDER BY path").bind(id).fetch_all(pool).await
418 +
}
419 +
420 +
/// The reports a job published, by name.
421 +
pub async fn of_job(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Report>> {
422 +
    sqlx::query_as("SELECT * FROM reports WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await
423 +
}
424 +
415 425
/// The capability a report's files are served under: they are read from a
416 426
/// sandboxed frame, which carries no session, so the link itself is what
417 427
/// lets it in, and only readers of the repository are given it.
…
111 unchanged lines
modified crates/hub/src/web/reports.rs +1 −1
…
418 unchanged lines
419 419
}
420 420
421 421
/// A file's type, by its extension.
422 -
fn content_type(path: &str) -> &'static str {
422 +
pub(crate) fn content_type(path: &str) -> &'static str {
423 423
    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
424 424
        "html" | "htm" => "text/html; charset=utf-8",
425 425
        "js" | "mjs" => "text/javascript; charset=utf-8",
…
88 unchanged lines
modified crates/hub/tests/flow.rs +55 −0
…
1171 unchanged lines
1172 1172
    assert!(page.contains("data-play=\"3\""), "playing");
1173 1173
    assert!(stop.contains("play") && stop.contains('3'), "playing carries on from stop to stop: {stop}");
1174 1174
    assert!(page.contains("data-report-runs="), "the frame interface knows the runs");
1175 +
1176 +
    // The same over the API (#348): the job's report with its files, one
1177 +
    // file as published, the newest of each, one by id, and plain 404s.
1178 +
    let n = run.number;
1179 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report"), None, None).await;
1180 +
    assert_eq!(st, StatusCode::OK, "{body}");
1181 +
    let r: serde_json::Value = serde_json::from_str(&body).unwrap();
1182 +
    assert_eq!((r["id"].as_i64(), r["name"].as_str(), r["kind"].as_str()), (Some(first), Some("frame-timing"), Some("performance")));
1183 +
    assert_eq!((r["metrics"][0]["key"].as_str(), r["metrics"][0]["fail_above"].as_f64()), (Some("median_ms"), Some(20.0)));
1184 +
    assert_eq!(r["context"]["target_fps"], 30);
1185 +
    let paths: Vec<&str> = r["manifest"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
1186 +
    assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"]);
1187 +
    assert!(r["url"].as_str().unwrap().ends_with(&format!("/andy/demo/reports/frame-timing?id={first}")));
1188 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/reports"), None, None).await;
1189 +
    assert!(st == StatusCode::OK && body.contains("\"frame-timing\"") && !body.contains("manifest"), "{st} {body}");
1190 +
    let res = oxygenhub::router(h.app.clone())
1191 +
        .oneshot(
1192 +
            Request::get(format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/data/frames.csv")).body(Body::empty()).unwrap(),
1193 +
        )
1194 +
        .await
1195 +
        .unwrap();
1196 +
    assert_eq!(res.status(), StatusCode::OK);
1197 +
    assert_eq!(res.headers()[header::CONTENT_TYPE], "text/csv; charset=utf-8");
1198 +
    assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], "sandbox; default-src 'none'", "a page in it runs nothing on the hub");
1199 +
    assert_eq!(res.into_body().collect().await.unwrap().to_bytes().to_vec(), data_bytes);
1200 +
    let toml_path = format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/report.toml?name=frame-timing");
1201 +
    assert_eq!(get_bytes(&h.app, &toml_path).await, (StatusCode::OK, toml.to_vec()));
1202 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/nope.csv"), None, None).await;
1203 +
    assert!(st == StatusCode::NOT_FOUND && body.contains("has no file nope.csv") && body.contains("report.toml"), "{st} {body}");
1204 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report?name=other"), None, None).await;
1205 +
    assert!(st == StatusCode::NOT_FOUND && body.contains("it published frame-timing"), "{st} {body}");
1206 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/nojob/report"), None, None).await;
1207 +
    assert!(st == StatusCode::NOT_FOUND && body.contains("its jobs: perf"), "{st} {body}");
1208 +
    // The newest of each is the second run; ?id= reaches the first.
1209 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports", None, None).await;
1210 +
    let latest: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
1211 +
    assert_eq!((latest.len(), latest[0]["id"].as_i64(), latest[0]["run"].as_i64()), (1, Some(second), Some(2)));
1212 +
    assert_eq!(latest[0]["metrics"][0]["value"].as_f64(), Some(18.0));
1213 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/frame-timing", None, None).await;
1214 +
    assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"].as_i64(), Some(second), "newest on its bookmark");
1215 +
    let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/reports/frame-timing?id={first}"), None, None).await;
1216 +
    assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["summary"], "Median 16.4 ms");
1217 +
    let page_path = format!("/api/v1/repos/andy/demo/reports/frame-timing/files/index.html?id={first}");
1218 +
    assert_eq!(get_bytes(&h.app, &page_path).await, (StatusCode::OK, page_bytes.clone()), "as published, no frame script");
1219 +
    let (st, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/frame-timing?bookmark=elsewhere", None, None).await;
1220 +
    assert!(st == StatusCode::NOT_FOUND && body.contains("never ran on elsewhere"), "{st} {body}");
1221 +
    let (st, _) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/nothing", None, None).await;
1222 +
    assert_eq!(st, StatusCode::NOT_FOUND);
1223 +
    // A run's jobs carry their timings, phases in the order taken.
1224 +
    let phases = std::collections::BTreeMap::from([("run".to_string(), 1200u64), ("image".to_string(), 300)]);
1225 +
    db::flow::set_phases(&h.app.db, id, &phases).await.unwrap();
1226 +
    let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}"), None, None).await;
1227 +
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
1228 +
    assert!(v["jobs"][0]["queued_ms"].as_i64().is_some(), "{body}");
1229 +
    assert_eq!(v["jobs"][0]["phases"], serde_json::json!([{ "name": "image", "ms": 300 }, { "name": "run", "ms": 1200 }]));
1175 1230
    let forged = src.replacen("/files/", "/files/9", 1);
1176 1231
    assert_eq!(get_bytes(&h.app, &forged).await.0, StatusCode::NOT_FOUND);
1177 1232
    assert_eq!(get_bytes(&h.app, &src.replace("/index.html", "/secret.txt")).await.0, StatusCode::NOT_FOUND);
…
6111 unchanged lines
modified crates/oh/src/client.rs +12 −0
…
79 unchanged lines
80 80
        }
81 81
    }
82 82
83 +
    /// GET raw bytes from the API (a report's file).
84 +
    pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
85 +
        match self.req("GET", path).call() {
86 +
            Ok(r) => {
87 +
                let mut out = Vec::new();
88 +
                std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
89 +
                Ok(out)
90 +
            }
91 +
            Err(e) => Self::finish(Err(e), &format!("GET {path}")).map(String::into_bytes),
92 +
        }
93 +
    }
94 +
83 95
    /// GET a path at the hub's root, outside the API (`/.well-known/…`).
84 96
    pub fn get_root_bytes(&self, path: &str) -> Result<Vec<u8>> {
85 97
        let hub = self.base.trim_end_matches("/api/v1");
…
14 unchanged lines
modified crates/oh/src/main.rs +273 −5
…
8 unchanged lines
9 9
use anyhow::{bail, Context, Result};
10 10
use clap::{Parser, Subcommand};
11 11
use client::Client;
12 -
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release, Tag};
12 +
use hub_api::{
13 +
    FlowJob, FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release, Report, Tag,
14 +
};
13 15
use std::time::Duration;
14 16
use target::{Choice, Config, Target};
15 17
…
51 unchanged lines
67 69
        #[command(subcommand)]
68 70
        cmd: RunCmd,
69 71
    },
72 +
    /// Reports Flow jobs publish: the newest of each, one by name (oxygenhub#348)
73 +
    Report {
74 +
        #[command(subcommand)]
75 +
        cmd: ReportCmd,
76 +
    },
70 77
    /// Releases
71 78
    Release {
72 79
        #[command(subcommand)]
…
584 unchanged lines
657 664
        limit: i64,
658 665
    },
659 666
    /// A run and its jobs
660 -
    View { number: i64 },
667 +
    View {
668 +
        number: i64,
669 +
        /// Each job's wait, phases (image, prepare, run, finish) and peak disk and memory
670 +
        #[arg(long)]
671 +
        timings: bool,
672 +
    },
661 673
    /// Run the pipeline without a push: a bookmark's current state (default: the default bookmark), or one change's
662 674
    Start {
663 675
        #[arg(long)]
…
12 unchanged lines
676 688
    Watch { number: i64 },
677 689
    /// A job's log
678 690
    Log { number: i64, job: String },
691 +
    /// A report a job published: its report.toml; --list for what it holds, --file for one file
692 +
    Report {
693 +
        number: i64,
694 +
        job: String,
695 +
        #[command(flatten)]
696 +
        pick: ReportPick,
697 +
    },
679 698
    /// Run a finished run's state again; with --job, only that job (and the
680 699
    /// jobs that depend on it) in the same run
681 700
    Rerun {
…
15 unchanged lines
697 716
        #[arg(long)]
698 717
        job: String,
699 718
    },
719 +
}
720 +
721 +
/// Which report of a job, and what of it to show.
722 +
#[derive(clap::Args)]
723 +
struct ReportPick {
724 +
    /// Which report, when the job published more than one
725 +
    #[arg(long)]
726 +
    name: Option<String>,
727 +
    /// What it holds: its metrics, context and files
728 +
    #[arg(long, conflicts_with = "file")]
729 +
    list: bool,
730 +
    /// One file of it (`data/frames.csv`), as published
731 +
    #[arg(long, value_name = "PATH")]
732 +
    file: Option<String>,
733 +
    /// Write the file here instead of to stdout
734 +
    #[arg(long, short, value_name = "FILE", requires = "file")]
735 +
    output: Option<std::path::PathBuf>,
700 736
}
701 737
702 738
#[derive(Subcommand)]
739 +
enum ReportCmd {
740 +
    /// The newest run of each report, newest first
741 +
    List {
742 +
        /// Only runs on this bookmark
743 +
        #[arg(long)]
744 +
        bookmark: Option<String>,
745 +
    },
746 +
    /// One report: its newest run on the default bookmark (or --bookmark), or the run --id names
747 +
    Show {
748 +
        report: String,
749 +
        #[arg(long, conflicts_with = "bookmark")]
750 +
        id: Option<i64>,
751 +
        #[arg(long)]
752 +
        bookmark: Option<String>,
753 +
        /// What it holds: its metrics, context and files
754 +
        #[arg(long, conflicts_with = "file")]
755 +
        list: bool,
756 +
        /// One file of it, as published
757 +
        #[arg(long, value_name = "PATH")]
758 +
        file: Option<String>,
759 +
        /// Write the file here instead of to stdout
760 +
        #[arg(long, short, value_name = "FILE", requires = "file")]
761 +
        output: Option<std::path::PathBuf>,
762 +
    },
763 +
}
764 +
765 +
#[derive(Subcommand)]
703 766
enum TagCmd {
704 767
    /// Tags, newest name first
705 768
    List,
…
133 unchanged lines
839 902
        Cmd::Issue { cmd } => issue(&c, &t, cmd, json),
840 903
        Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json),
841 904
        Cmd::Run { cmd } => flow(&c, &t, cmd, json),
905 +
        Cmd::Report { cmd } => report(&c, &t, cmd, json),
842 906
        Cmd::Coverage { cmd: CoverageCmd::Upload { file, bookmark, change } } => {
843 907
            let content = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?;
844 908
            let mut body = serde_json::json!({ "content": content });
…
768 unchanged lines
1613 1677
    )
1614 1678
}
1615 1679
1616 -
fn print_run(r: &FlowRun) {
1680 +
fn print_run(r: &FlowRun, timings: bool) {
1617 1681
    println!("{}", run_line(r));
1618 1682
    for j in &r.jobs {
1619 1683
        let dur = match (j.started_ms, j.finished_ms) {
…
9 unchanged lines
1629 1693
        } else if !j.placed.is_empty() {
1630 1694
            println!("    {}", ui::dim(&format!("placed: {}", j.placed)));
1631 1695
        }
1696 +
        if timings {
1697 +
            let t = timing_line(j);
1698 +
            if !t.is_empty() {
1699 +
                println!("    {}", ui::dim(&t));
1700 +
            }
1701 +
        }
1632 1702
    }
1633 1703
    if !r.error.is_empty() {
1634 1704
        println!("{}", ui::bad(&r.error));
…
1 unchanged lines
1636 1706
    println!("{}", ui::dim(&r.url));
1637 1707
}
1638 1708
1709 +
/// Milliseconds as a person reads them: "850 ms", "3.1 s", "2 min 5 s".
1710 +
fn took(ms: i64) -> String {
1711 +
    match ms {
1712 +
        ..=999 => format!("{ms} ms"),
1713 +
        1000..=59_999 => format!("{:.1} s", ms as f64 / 1000.0),
1714 +
        _ => format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000),
1715 +
    }
1716 +
}
1717 +
1718 +
/// MiB as a person reads them.
1719 +
fn mib(m: i64) -> String {
1720 +
    if m >= 1024 {
1721 +
        format!("{:.1} GiB", m as f64 / 1024.0)
1722 +
    } else {
1723 +
        format!("{m} MiB")
1724 +
    }
1725 +
}
1726 +
1727 +
/// Where a job's time went (oxygenhub#348): its wait for a runner, each
1728 +
/// phase its runner reported, and the most disk and memory it took.
1729 +
fn timing_line(j: &FlowJob) -> String {
1730 +
    let mut parts = vec![];
1731 +
    if let (Some(q), Some(s)) = (j.queued_ms, j.started_ms) {
1732 +
        parts.push(format!("waited {}", took((s - q).max(0))));
1733 +
    }
1734 +
    parts.extend(j.phases.iter().map(|p| format!("{} {}", p.name, took(p.ms as i64))));
1735 +
    let peaks: Vec<String> = [("disk", j.peak_disk_mib), ("memory", j.peak_memory_mib)]
1736 +
        .into_iter()
1737 +
        .filter_map(|(what, m)| m.map(|m| format!("{what} {}", mib(m))))
1738 +
        .collect();
1739 +
    if !peaks.is_empty() {
1740 +
        parts.push(format!("peak {}", peaks.join(", ")));
1741 +
    }
1742 +
    parts.join(" · ")
1743 +
}
1744 +
1745 +
/// A report's path, its segments encoded and its slashes kept.
1746 +
fn enc_path(path: &str) -> String {
1747 +
    path.split('/').map(enc).collect::<Vec<_>>().join("/")
1748 +
}
1749 +
1750 +
/// `oh run report` and `oh report show`: the report at `at` (JSON), whose
1751 +
/// files are at `files(path)`. By default its report.toml; with `list`
1752 +
/// what it holds; with `file` one file, to stdout or `output`.
1753 +
fn show_report(
1754 +
    c: &Client,
1755 +
    at: &str,
1756 +
    files: impl Fn(&str) -> String,
1757 +
    list: bool,
1758 +
    file: Option<String>,
1759 +
    output: Option<std::path::PathBuf>,
1760 +
    json: bool,
1761 +
) -> Result<()> {
1762 +
    use std::io::Write;
1763 +
    if let Some(path) = file {
1764 +
        let bytes = c.get_bytes(&files(&path))?;
1765 +
        match output {
1766 +
            Some(out) => {
1767 +
                std::fs::write(&out, &bytes).with_context(|| format!("writing {}", out.display()))?;
1768 +
                eprintln!("{} {path}: {} bytes to {}", ui::ok("✔"), bytes.len(), out.display());
1769 +
            }
1770 +
            None => std::io::stdout().write_all(&bytes)?,
1771 +
        }
1772 +
        return Ok(());
1773 +
    }
1774 +
    if json {
1775 +
        println!("{}", c.get_text(at)?);
1776 +
        return Ok(());
1777 +
    }
1778 +
    if !list {
1779 +
        std::io::stdout().write_all(&c.get_bytes(&files("report.toml"))?)?;
1780 +
        return Ok(());
1781 +
    }
1782 +
    let r: Report = c.get(at)?;
1783 +
    print_report(&r);
1784 +
    if r.files_expired_ms.is_some() {
1785 +
        println!("{}", ui::warn("its files expired under retention; its metrics stay"));
1786 +
    }
1787 +
    for f in &r.manifest {
1788 +
        println!("  {:>10}  {}", ui::dim(&f.size.to_string()), f.path);
1789 +
    }
1790 +
    println!("{}", ui::dim(&r.url));
1791 +
    Ok(())
1792 +
}
1793 +
1794 +
/// A report's heading, metrics and context.
1795 +
fn print_report(r: &Report) {
1796 +
    let from = match r.run {
1797 +
        Some(n) => format!("run #{n} {} on {}", r.job, r.bookmark),
1798 +
        None => format!("upload on {}", if r.bookmark.is_empty() { "a change" } else { &r.bookmark }),
1799 +
    };
1800 +
    println!(
1801 +
        "{} {} {}",
1802 +
        ui::strong(&r.name),
1803 +
        ui::dim(&format!("{} · {:?}", r.kind, r.title)),
1804 +
        ui::dim(&format!("· {from} · {} files", r.files))
1805 +
    );
1806 +
    if !r.summary.is_empty() {
1807 +
        println!("  {}", r.summary);
1808 +
    }
1809 +
    for m in &r.metrics {
1810 +
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1811 +
        let mut notes = vec![];
1812 +
        if !m.better.is_empty() {
1813 +
            notes.push(format!("{} is better", m.better));
1814 +
        }
1815 +
        if let Some(a) = m.fail_above {
1816 +
            notes.push(format!("fails above {a}"));
1817 +
        }
1818 +
        if let Some(b) = m.fail_below {
1819 +
            notes.push(format!("fails below {b}"));
1820 +
        }
1821 +
        let notes = if notes.is_empty() { String::new() } else { format!("  ({})", notes.join(", ")) };
1822 +
        println!("  {} {}{unit}{}", m.key, m.value, ui::dim(&notes));
1823 +
    }
1824 +
    if let Some(ctx) = r.context.as_object().filter(|m| !m.is_empty()) {
1825 +
        let line: Vec<String> =
1826 +
            ctx.iter().map(|(k, v)| format!("{k}={}", v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect();
1827 +
        println!("  {}", ui::dim(&line.join(" ")));
1828 +
    }
1829 +
}
1830 +
1831 +
/// `oh report …` (oxygenhub#348).
1832 +
fn report(c: &Client, t: &Target, cmd: ReportCmd, json: bool) -> Result<i32> {
1833 +
    let base = t.repo_path()?;
1834 +
    match cmd {
1835 +
        ReportCmd::List { bookmark } => {
1836 +
            let path = format!("{base}/reports{}", bookmark.map(|b| format!("?bookmark={}", enc(&b))).unwrap_or_default());
1837 +
            if json {
1838 +
                println!("{}", c.get_text(&path)?);
1839 +
                return Ok(0);
1840 +
            }
1841 +
            let list: Vec<Report> = c.get(&path)?;
1842 +
            if list.is_empty() {
1843 +
                println!("{}", ui::dim("No reports yet: a Flow job publishes one with `output { report \"name\" { path … } }`."));
1844 +
            }
1845 +
            for r in &list {
1846 +
                print_report(r);
1847 +
            }
1848 +
        }
1849 +
        ReportCmd::Show { report, id, bookmark, list, file, output } => {
1850 +
            let at = format!("{base}/reports/{}", enc(&report));
1851 +
            let query = match (id, bookmark) {
1852 +
                (Some(id), _) => format!("?id={id}"),
1853 +
                (None, Some(b)) => format!("?bookmark={}", enc(&b)),
1854 +
                (None, None) => String::new(),
1855 +
            };
1856 +
            let files = |path: &str| format!("{at}/files/{}{query}", enc_path(path));
1857 +
            show_report(c, &format!("{at}{query}"), files, list, file, output, json)?;
1858 +
        }
1859 +
    }
1860 +
    Ok(0)
1861 +
}
1862 +
1639 1863
fn live(status: &str) -> bool {
1640 1864
    matches!(status, "queued" | "running")
1641 1865
}
…
55 unchanged lines
1697 1921
                println!("{}", run_line(r));
1698 1922
            }
1699 1923
        }
1700 -
        RunCmd::View { number } => {
1924 +
        RunCmd::View { number, timings } => {
1701 1925
            let path = format!("{base}/flow/runs/{number}");
1702 1926
            if json {
1703 1927
                println!("{}", c.get_text(&path)?);
1704 1928
                return Ok(0);
1705 1929
            }
1706 -
            print_run(&c.get(&path)?);
1930 +
            print_run(&c.get(&path)?, timings);
1707 1931
        }
1708 1932
        RunCmd::Start { bookmark, change, jobs, watch: follow } => {
1709 1933
            let mut body = match (bookmark, change) {
…
37 unchanged lines
1747 1971
                }
1748 1972
                after = lines.last().map(|l| l.seq).unwrap_or(after);
1749 1973
            }
1974 +
        }
1975 +
        RunCmd::Report { number, job, pick } => {
1976 +
            let at = format!("{base}/flow/runs/{number}/jobs/{}/report", enc(&job));
1977 +
            let query = pick.name.as_deref().map(|n| format!("?name={}", enc(n))).unwrap_or_default();
1978 +
            let files = |path: &str| format!("{at}/{}{query}", enc_path(path));
1979 +
            show_report(c, &format!("{at}{query}"), files, pick.list, pick.file, pick.output, json)?;
1750 1980
        }
1751 1981
        RunCmd::Rerun { number, job: Some(job) } => {
1752 1982
            let queued: serde_json::Value =
…
501 unchanged lines
2254 2484
2255 2485
#[cfg(test)]
2256 2486
mod tests {
2487 +
2488 +
    #[test]
2489 +
    fn job_timings_and_report_commands_read_plainly() {
2490 +
        use clap::Parser;
2491 +
        let job = hub_api::FlowJob {
2492 +
            name: "perf".into(),
2493 +
            status: "passed".into(),
2494 +
            depends_on: vec![],
2495 +
            runner: "earth-1".into(),
2496 +
            exit_code: Some(0),
2497 +
            error: String::new(),
2498 +
            started_ms: Some(13_000),
2499 +
            finished_ms: Some(200_000),
2500 +
            log_lines: 10,
2501 +
            waiting: String::new(),
2502 +
            placed: String::new(),
2503 +
            approval: String::new(),
2504 +
            queued_ms: Some(1_000),
2505 +
            ready_ms: Some(12_500),
2506 +
            phases: vec![hub_api::FlowPhase { name: "image".into(), ms: 850 }, hub_api::FlowPhase { name: "run".into(), ms: 125_400 }],
2507 +
            peak_disk_mib: Some(2150),
2508 +
            peak_memory_mib: Some(900),
2509 +
        };
2510 +
        assert_eq!(super::timing_line(&job), "waited 12.0 s · image 850 ms · run 2 min 5 s · peak disk 2.1 GiB, memory 900 MiB");
2511 +
        let bare = hub_api::FlowJob { queued_ms: None, phases: vec![], peak_disk_mib: None, peak_memory_mib: None, ..job };
2512 +
        assert_eq!(super::timing_line(&bare), "");
2513 +
        assert_eq!(super::enc_path("data/frame times.csv"), "data/frame%20times.csv");
2514 +
2515 +
        let cli =
2516 +
            super::Cli::try_parse_from(["oh", "run", "report", "26", "performance", "--file", "data/frames.csv", "-o", "f.csv"]).unwrap();
2517 +
        let super::Cmd::Run { cmd: super::RunCmd::Report { number, job, pick } } = cli.cmd else { panic!("run report") };
2518 +
        assert_eq!((number, job.as_str(), pick.file.as_deref(), pick.list), (26, "performance", Some("data/frames.csv"), false));
2519 +
        assert!(super::Cli::try_parse_from(["oh", "run", "report", "26", "perf", "--list", "--file", "x"]).is_err(), "one or the other");
2520 +
        assert!(super::Cli::try_parse_from(["oh", "run", "report", "26", "perf", "-o", "x"]).is_err(), "-o is for --file");
2521 +
        let cli = super::Cli::try_parse_from(["oh", "report", "show", "performance", "--bookmark", "main", "--list"]).unwrap();
2522 +
        assert!(matches!(cli.cmd, super::Cmd::Report { cmd: super::ReportCmd::Show { list: true, .. } }));
2523 +
        assert!(super::Cli::try_parse_from(["oh", "run", "view", "3", "--timings"]).is_ok());
2524 +
    }
2257 2525
2258 2526
    #[test]
2259 2527
    fn runner_update_scopes_resolve_by_name_or_id() {
…
64 unchanged lines
modified docs/reports.md +32 −9
…
156 unchanged lines
157 157
158 158
## 6. The API and `oh`
159 159
160 -
- `GET /repos/{o}/{n}/reports` (`?bookmark=`): the latest of each.
161 -
- `GET /repos/{o}/{n}/reports/{name}` (`?bookmark=&limit=&before=`): its
162 -
  history, metrics included.
163 -
- `GET /repos/{o}/{n}/reports/{name}/{id}`: one report, and
164 -
  `…/{id}/files/{path}` its files.
165 -
- `POST /repos/{o}/{n}/reports/{name}` (repo:write): an upload.
166 -
- `oh report list | view NAME | upload NAME DIR`.
160 +
Built (oxygenhub#348), read access as the repository's pages:
167 161
162 +
- `GET /repos/{o}/{n}/flow/runs/{n}/jobs/{job}/reports`: the reports a job
163 +
  published, with their metrics and context.
164 +
- `GET /repos/{o}/{n}/flow/runs/{n}/jobs/{job}/report` (`?name=` when the
165 +
  job published more than one): the report with its files (`manifest`:
166 +
  path and size), and `…/report/{path}` one file as published.
167 +
- `GET /repos/{o}/{n}/reports` (`?bookmark=`): the newest run of each.
168 +
- `GET /repos/{o}/{n}/reports/{name}` (`?id=`, the page's run id, or
169 +
  `?bookmark=`, default the repository's default bookmark, else wherever
170 +
  it ran last): one run with its files, and `…/{name}/files/{path}` one
171 +
  file of it.
172 +
173 +
A file over the API comes as published (an HTML page without the frame
174 +
script), with its type and `Content-Security-Policy: sandbox;
175 +
default-src 'none'`, so nothing in it runs on the hub's origin. A run
176 +
whose files expired answers `410` for them; its metrics stay. The errors
177 +
name what there is: the run's jobs, the job's reports, the report's files.
178 +
179 +
- `oh run report RUN JOB` prints the report's `report.toml`; `--list`
180 +
  shows its metrics, context and files; `--file data/frames.csv` writes
181 +
  one file to stdout or `-o FILE`; `--name` picks among a job's reports;
182 +
  `--json` gives the API's answer.
183 +
- `oh report list [--bookmark B]` and `oh report show NAME [--bookmark B |
184 +
  --id ID] [--list | --file PATH [-o FILE]]`.
185 +
186 +
Still to come: a report's history over the API (`?limit=&before=`) and
187 +
uploads from anywhere else (`POST /repos/{o}/{n}/reports/{name}`, `oh
188 +
report upload NAME DIR`, §2).
189 +
168 190
## 7. For a project adopting it
169 191
170 192
1. Make the test or benchmark job write `out/report/` with a `report.toml`
…
44 unchanged lines
215 237
collection: every run on the default bookmark keeps its files, and the
216 238
newest 50 runs of each report on any other bookmark (proposal and tag
217 239
rules, and a per-repository setting, follow); an expired run keeps its
218 -
metrics and summary, and its page says its files expired. `oh report`
219 -
(4) follows.
240 +
metrics and summary, and its page says its files expired. *2026-10-28:*
241 +
reading reports over the API and `oh run report` / `oh report` (4) are
242 +
built (§6); uploads and history over the API follow.
220 243
221 244
222 245
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
…
5 unchanged lines
modified docs/spec.md +1 −1
…
491 unchanged lines
492 492
493 493
### 12.1 API
494 494
495 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
495 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
496 496
497 497
### 12.2 Webhooks
498 498
…
396 unchanged lines