OXYGENHUB
# c8df33a6829f oxygenhub / docs / reports.md
8.4 KiB · Markdown · last changed Reports: the Reports tab, each report's page, its frame, and checks from thresholds 25 days ago renderedsource blame history raw
50d93fa4 Andy McCall ↶ 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
a36bab6b Andy McCall ↶ 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
50d93fa4 Andy McCall ↶ 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.
a36bab6b Andy McCall ↶ 104
   - `report_files`: one row per file per report: path, blob, size; what
105
     garbage collection keeps and the frame serves.
50d93fa4 Andy McCall ↶ 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
c8df33a6 Andy McCall ↶ 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
50d93fa4 Andy McCall ↶ 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.