OXYGENHUB
# 76f93eaefe45 oxygenhub / docs / reports.md
10.0 KiB · Markdown · last changed Reports retention: files beyond the newest 50 runs off the default bookmark expire 24 days ago renderedsource blame history raw
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
### Fitting the hub
178
179
- **Height:** the hub adds a small script to a report's HTML pages
180
  (`/static/report-frame.js`) that tells the page around the frame how
181
  tall the report is, so the frame grows to fit and the report never
182
  scrolls inside it. Lay the page out by its content: nothing sized to the
183
  viewport (`100vh`, `height: 100%` on `html`/`body`), or it grows with
184
  the frame.
185
- **Look:** link the hub's stylesheet, and drop any fonts or colours of the
186
  report's own:
187
188
  ```html
189
  <link rel="stylesheet" href="/static/report.css">
190
  ```
191
192
  It sets the hub's fonts and colour tokens (`--bg`, `--panel`, `--soft`,
193
  `--ink`, `--mute`, `--line`, `--acc`, `--ok`, `--bad`, `--warn`,
194
  `--m1`..`--m5` for series), dark or light as the hub is, switching when
195
  the viewer switches. Charts read them with
196
  `getComputedStyle(document.documentElement).getPropertyValue("--m1")`
197
  and redraw on a `data-theme` change if they cache colours.
198
199
## 8. Order of work
200
201
*Status, 2026-10-11:* storage and index (1) are deployed; the Reports tab,
202
each report's page with its metrics, changes and sparklines, its own page
203
in a sandboxed frame, and checks from thresholds (2) are built, Coverage
204
and Tests listed on the tab while their own pages stay. The frame is
205
sandboxed (`allow-scripts` only) rather than on a second origin: it
206
carries no session, so its files are served under a per-report MAC in the
207
link, with `Content-Security-Policy: sandbox allow-scripts` and nothing
208
beyond this hub to load. The timeline (3) is built too: one stop per
209
run on the bookmark, arrow keys to step, Play at 1 to 10 s a run (carried
210
in the URL as `play`), Compare against any other run (`compare`) with
211
every metric's difference and the two pages side by side, and the frame
212
interface: inside the frame, `report.run()` and `report.history("file")`
213
answer from the hub page by postMessage, `history` reading that file of
214
`data/` from each run's capability URL. Retention (§3.3) runs before each garbage
215
collection: every run on the default bookmark keeps its files, and the
216
newest 50 runs of each report on any other bookmark (proposal and tag
217
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.
220
221
222
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
223
   bundle upload, the hub's tree, tables and gc roots; coverage and junit
224
   read through it.
225
2. The Reports tab and kinds; the sandboxed frame; metrics and checks.
226
3. The timeline: scrub, play, compare, and the frame interface.
227
4. `oh report` and the API; this document's §7 as the guide.