| 1 |
# Reports |
| 2 |
|
| 3 |
*Design, 2026-10-11. Supersedes the separate Coverage and Tests tabs |
| 4 |
(spec §13.6, §13.7), which become two kinds of report.* |
| 5 |
|
| 6 |
A project produces more than pass and fail: coverage, test results, |
| 7 |
frame timings, sizes, benchmark curves. Today the hub reads two of them, |
| 8 |
LCOV coverage and JUnit tests, each with a tab of its own. This design |
| 9 |
makes reports one thing: a project declares what a report is, the hub |
| 10 |
keeps every run's copy, and readers move through its history. |
| 11 |
|
| 12 |
## 1. What a report is |
| 13 |
|
| 14 |
A **report** is a directory a Flow job writes, or anyone uploads: |
| 15 |
|
| 16 |
``` |
| 17 |
report/ |
| 18 |
report.toml the declaration (required) |
| 19 |
index.html a page to show (custom and performance kinds) |
| 20 |
data/*.csv|json datasets the page draws from |
| 21 |
assets/… images, scripts, styles: everything the page needs |
| 22 |
``` |
| 23 |
|
| 24 |
`report.toml` says what it is: |
| 25 |
|
| 26 |
```toml |
| 27 |
kind = "performance" # coverage | tests | performance | custom |
| 28 |
title = "Frame timing" |
| 29 |
summary = "Median frame 16.4 ms, p99 18.1 ms over 12 scenes" |
| 30 |
|
| 31 |
[metrics] # the numbers the hub tracks across runs |
| 32 |
median_frame_ms = { value = 16.4, unit = "ms", better = "lower" } |
| 33 |
p99_frame_ms = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 } |
| 34 |
scenes = { value = 12 } |
| 35 |
``` |
| 36 |
|
| 37 |
- **Built-in kinds** name their data file and need no page: |
| 38 |
`kind = "coverage"` with `file = "lcov.info"` (LCOV or Cobertura), and |
| 39 |
`kind = "tests"` with `file = "junit.xml"`. The hub reads them as it |
| 40 |
does today, and works out their metrics itself (line coverage; passed, |
| 41 |
failed, skipped). |
| 42 |
- **`performance`** is a page with metrics, as above. |
| 43 |
- **`custom`** is a page, metrics optional. |
| 44 |
- **A metric** is a number with an optional `unit`, `better` (`higher` or |
| 45 |
`lower`, for colouring changes) and `fail_above` / `fail_below`, which |
| 46 |
set a check on the change (§5). |
| 47 |
|
| 48 |
**Implicit declarations:** a job artifact named `coverage` or `junit`, the |
| 49 |
names used today, is read as a report of that kind with no `report.toml`, |
| 50 |
so every existing pipeline keeps working unchanged. |
| 51 |
|
| 52 |
## 2. How a report arrives |
| 53 |
|
| 54 |
**From Flow**, a job declares it in its outputs: |
| 55 |
|
| 56 |
```kdl |
| 57 |
job "perf" { |
| 58 |
exec "sh" "-eu" ".flow/jobs/perf.sh" |
| 59 |
output { |
| 60 |
report "frame-timing" { |
| 61 |
path "/src/out/report" |
| 62 |
when "always" |
| 63 |
} |
| 64 |
} |
| 65 |
} |
| 66 |
``` |
| 67 |
|
| 68 |
The runner uploads the directory as one bundle. `when "always"` keeps a |
| 69 |
failing job's report, as for artifacts. |
| 70 |
|
| 71 |
The bundle travels file by file: each to |
| 72 |
`POST /api/v1/flow/jobs/{job}/reports/{name}/files` with its path in |
| 73 |
`X-Flow-Report-Path`, answered with its blob; then the manifest, |
| 74 |
`{"version":1,"files":[{"path","blob","size"}]}`, as the job's artifact |
| 75 |
under the report's name, which the receipt names. The hub refuses a |
| 76 |
manifest naming a file it does not hold, reads `report.toml` and indexes |
| 77 |
the report; a `report.toml` it cannot read is kept as a custom report whose |
| 78 |
summary says why, so the job's log and the tab show what to fix. Paths are |
| 79 |
`/`-separated names of letters, digits, `.`, `_`, `-` and `+`; a report |
| 80 |
holds at most 2000 files and 512 MiB. |
| 81 |
|
| 82 |
**From anywhere else:** |
| 83 |
`oh report upload frame-timing ./report --bookmark develop` attaches it to |
| 84 |
the bookmark's current state, or `--change ID` to a change's. The API is |
| 85 |
`POST /repos/{o}/{n}/reports/{name}` with the bundle as a tar. |
| 86 |
|
| 87 |
## 3. How the hub keeps it |
| 88 |
|
| 89 |
Three layers. |
| 90 |
|
| 91 |
1. **The bundle, per job, content-addressed.** Each file is a blob in the |
| 92 |
repository's own store, chunked and deduplicated as artifacts are, and |
| 93 |
the bundle is a small **tree**: a manifest of path → blob. A file that |
| 94 |
did not change since the last run (the page, its scripts, a dataset that |
| 95 |
did not move) is stored once however many runs name it; one that |
| 96 |
changed a little costs only its changed chunks. Keeping every run's |
| 97 |
report is cheap. |
| 98 |
2. **An index in the database:** |
| 99 |
- `reports`: one row per report per job or upload: repository, name, |
| 100 |
kind, title, summary, run and job (or uploader), bookmark, the |
| 101 |
measured state and heads, time, and the tree's blob. |
| 102 |
- `report_metrics`: one row per metric per report: key, value, unit, |
| 103 |
better, thresholds. |
| 104 |
- `report_files`: one row per file per report: path, blob, size; what |
| 105 |
garbage collection keeps and the frame serves. |
| 106 |
|
| 107 |
History, trends, the scrubber and checks read only this. Coverage's |
| 108 |
and Tests' existing tables become their kinds' details, keyed by |
| 109 |
report. |
| 110 |
3. **Retention, by policy:** |
| 111 |
- metrics: forever (they are a few bytes a run); |
| 112 |
- bundles: every run on the default bookmark; the last 50 on any other |
| 113 |
bookmark; a proposal's until it lands or closes plus 30 days; a tagged |
| 114 |
release's forever. Set per repository in Settings › Reports. |
| 115 |
|
| 116 |
An expired bundle's row keeps its metrics and loses its tree; the blobs |
| 117 |
go at the next garbage collection. |
| 118 |
|
| 119 |
**Garbage collection** keeps every tree and file blob a kept report names, |
| 120 |
as it keeps artifacts (oxygenhub#126): the database is the only thing that |
| 121 |
names them. |
| 122 |
|
| 123 |
## 4. What readers see |
| 124 |
|
| 125 |
- **A Reports tab** (in place of Coverage and Tests), shown once a |
| 126 |
repository has a report: each report's latest on the chosen bookmark, |
| 127 |
with its kind, summary, metrics and their change against the run |
| 128 |
before. Coverage and Tests are entries in it; their old addresses |
| 129 |
redirect. |
| 130 |
- **A report's page:** |
| 131 |
- **built-in kinds** show their own views (the coverage tree and file |
| 132 |
view, the test cases and their history), as today; |
| 133 |
- **performance and custom** show `index.html` in a **sandboxed frame |
| 134 |
served from a separate origin**, with no network access and no access |
| 135 |
to the hub's cookies or session, so a page may run any scripts and |
| 136 |
charts it likes and still cannot act as the viewer; |
| 137 |
- **metrics** as a strip of numbers with sparklines across runs. |
| 138 |
- **The timeline:** along the top, one stop per run on the bookmark, |
| 139 |
oldest to newest, each labelled with the change it measured. |
| 140 |
- **Scrub** to any run, or step with the arrow keys. |
| 141 |
- **Play** steps through the runs at a chosen speed, so a chart can be |
| 142 |
watched changing. |
| 143 |
- **Compare** two runs side by side, with every metric's delta; on a |
| 144 |
proposal the default is its base against its head. |
| 145 |
- **A frame interface** for pages that animate rather than reload: inside |
| 146 |
the frame, `report.run()` gives the run on screen, and |
| 147 |
`report.history("dataset")` that dataset across the runs, so a page can |
| 148 |
redraw as the timeline moves or chart its own trends. |
| 149 |
- **Downloads:** the whole bundle as a zip, and each dataset alone. |
| 150 |
|
| 151 |
## 5. Checks |
| 152 |
|
| 153 |
A metric with `fail_above` or `fail_below` sets a check named |
| 154 |
`report/NAME/metric` on the change, failing when crossed, which a proposal |
| 155 |
may require (Settings › Protection). Coverage's existing check becomes |
| 156 |
this. |
| 157 |
|
| 158 |
## 6. The API and `oh` |
| 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`. |
| 167 |
|
| 168 |
## 7. For a project adopting it |
| 169 |
|
| 170 |
1. Make the test or benchmark job write `out/report/` with a `report.toml` |
| 171 |
and, for a page, `index.html` with its libraries bundled (no CDN: the |
| 172 |
frame has no network) and its data in `data/`. |
| 173 |
2. Declare `report "NAME" { path "/src/out/report" when "always" }` in the |
| 174 |
job's output. |
| 175 |
3. Push. The report appears on the Reports tab after the run. |
| 176 |
|
| 177 |
## 8. Order of work |
| 178 |
|
| 179 |
*Status, 2026-10-11:* storage and index (1) are deployed; the Reports tab, |
| 180 |
each report's page with its metrics, changes and sparklines, its own page |
| 181 |
in a sandboxed frame, and checks from thresholds (2) are built, Coverage |
| 182 |
and Tests listed on the tab while their own pages stay. The frame is |
| 183 |
sandboxed (`allow-scripts` only) rather than on a second origin: it |
| 184 |
carries no session, so its files are served under a per-report MAC in the |
| 185 |
link, with `Content-Security-Policy: sandbox allow-scripts` and nothing |
| 186 |
beyond this hub to load. Retention (§3.3), the timeline (3) and `oh |
| 187 |
report` (4) follow. |
| 188 |
|
| 189 |
|
| 190 |
1. Storage and index: the `report` output kind in OxygenFlow, the runner's |
| 191 |
bundle upload, the hub's tree, tables and gc roots; coverage and junit |
| 192 |
read through it. |
| 193 |
2. The Reports tab and kinds; the sandboxed frame; metrics and checks. |
| 194 |
3. The timeline: scrub, play, compare, and the frame interface. |
| 195 |
4. `oh report` and the API; this document's §7 as the guide. |