OXYGENHUB

docs/reports.md: the Reports design 50d93fa443dc

One Reports tab whose reports declare their kind (coverage, tests,
performance, custom) in report.toml; bundles per Flow job kept as
content-addressed trees, indexed with their metrics, retained by policy
and rooted for gc; a sandboxed frame on a separate origin; a timeline to
scrub, play and compare runs; checks from metric thresholds; oh report.

refs #127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-10 23:35 UTC
Signature
✔ signed by Andy McCall
Hash
50d93fa443dc4807ada1712021441f0437b6d332b70e96203b1ab040a2609506
Depends on
93be9ce4653e
Depended on by
fac6853c4e46 17dc8de4fca8 20b8fe85888b 95b525b0b977 f3e5e2bc78e9 c8df33a6829f a36bab6b9ffc 260c33b4b6c4
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.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
1 atom, 1 tree operation, 1 file

Browse the tree at this change

added docs/reports.md +171 −0
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 +
**From anywhere else:**
72 +
`oh report upload frame-timing ./report --bookmark develop` attaches it to
73 +
the bookmark's current state, or `--change ID` to a change's. The API is
74 +
`POST /repos/{o}/{n}/reports/{name}` with the bundle as a tar.
75 +
76 +
## 3. How the hub keeps it
77 +
78 +
Three layers.
79 +
80 +
1. **The bundle, per job, content-addressed.** Each file is a blob in the
81 +
   repository's own store, chunked and deduplicated as artifacts are, and
82 +
   the bundle is a small **tree**: a manifest of path → blob. A file that
83 +
   did not change since the last run (the page, its scripts, a dataset that
84 +
   did not move) is stored once however many runs name it; one that
85 +
   changed a little costs only its changed chunks. Keeping every run's
86 +
   report is cheap.
87 +
2. **An index in the database:**
88 +
   - `reports`: one row per report per job or upload: repository, name,
89 +
     kind, title, summary, run and job (or uploader), bookmark, the
90 +
     measured state and heads, time, and the tree's blob.
91 +
   - `report_metrics`: one row per metric per report: key, value, unit,
92 +
     better, thresholds.
93 +
94 +
   History, trends, the scrubber and checks read only this. Coverage's
95 +
   and Tests' existing tables become their kinds' details, keyed by
96 +
   report.
97 +
3. **Retention, by policy:**
98 +
   - metrics: forever (they are a few bytes a run);
99 +
   - bundles: every run on the default bookmark; the last 50 on any other
100 +
     bookmark; a proposal's until it lands or closes plus 30 days; a tagged
101 +
     release's forever. Set per repository in Settings › Reports.
102 +
103 +
   An expired bundle's row keeps its metrics and loses its tree; the blobs
104 +
   go at the next garbage collection.
105 +
106 +
**Garbage collection** keeps every tree and file blob a kept report names,
107 +
as it keeps artifacts (oxygenhub#126): the database is the only thing that
108 +
names them.
109 +
110 +
## 4. What readers see
111 +
112 +
- **A Reports tab** (in place of Coverage and Tests), shown once a
113 +
  repository has a report: each report's latest on the chosen bookmark,
114 +
  with its kind, summary, metrics and their change against the run
115 +
  before. Coverage and Tests are entries in it; their old addresses
116 +
  redirect.
117 +
- **A report's page:**
118 +
  - **built-in kinds** show their own views (the coverage tree and file
119 +
    view, the test cases and their history), as today;
120 +
  - **performance and custom** show `index.html` in a **sandboxed frame
121 +
    served from a separate origin**, with no network access and no access
122 +
    to the hub's cookies or session, so a page may run any scripts and
123 +
    charts it likes and still cannot act as the viewer;
124 +
  - **metrics** as a strip of numbers with sparklines across runs.
125 +
- **The timeline:** along the top, one stop per run on the bookmark,
126 +
  oldest to newest, each labelled with the change it measured.
127 +
  - **Scrub** to any run, or step with the arrow keys.
128 +
  - **Play** steps through the runs at a chosen speed, so a chart can be
129 +
    watched changing.
130 +
  - **Compare** two runs side by side, with every metric's delta; on a
131 +
    proposal the default is its base against its head.
132 +
- **A frame interface** for pages that animate rather than reload: inside
133 +
  the frame, `report.run()` gives the run on screen, and
134 +
  `report.history("dataset")` that dataset across the runs, so a page can
135 +
  redraw as the timeline moves or chart its own trends.
136 +
- **Downloads:** the whole bundle as a zip, and each dataset alone.
137 +
138 +
## 5. Checks
139 +
140 +
A metric with `fail_above` or `fail_below` sets a check named
141 +
`report/NAME/metric` on the change, failing when crossed, which a proposal
142 +
may require (Settings › Protection). Coverage's existing check becomes
143 +
this.
144 +
145 +
## 6. The API and `oh`
146 +
147 +
- `GET /repos/{o}/{n}/reports` (`?bookmark=`): the latest of each.
148 +
- `GET /repos/{o}/{n}/reports/{name}` (`?bookmark=&limit=&before=`): its
149 +
  history, metrics included.
150 +
- `GET /repos/{o}/{n}/reports/{name}/{id}`: one report, and
151 +
  `…/{id}/files/{path}` its files.
152 +
- `POST /repos/{o}/{n}/reports/{name}` (repo:write): an upload.
153 +
- `oh report list | view NAME | upload NAME DIR`.
154 +
155 +
## 7. For a project adopting it
156 +
157 +
1. Make the test or benchmark job write `out/report/` with a `report.toml`
158 +
   and, for a page, `index.html` with its libraries bundled (no CDN: the
159 +
   frame has no network) and its data in `data/`.
160 +
2. Declare `report "NAME" { path "/src/out/report" when "always" }` in the
161 +
   job's output.
162 +
3. Push. The report appears on the Reports tab after the run.
163 +
164 +
## 8. Order of work
165 +
166 +
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
167 +
   bundle upload, the hub's tree, tables and gc roots; coverage and junit
168 +
   read through it.
169 +
2. The Reports tab and kinds; the sandboxed frame; metrics and checks.
170 +
3. The timeline: scrub, play, compare, and the frame interface.
171 +
4. `oh report` and the API; this document's §7 as the guide.