Overview

Cut #2a registered the two advisory loops (determinism-review, a11y-review) behind an AdvisoryTail spec. Nothing read it yet — the dashboard still hard-coded a determinism tab and an a11y tab as near-identical twins. This cut makes the registration load-bearing: the dashboard now derives its advisory tabs from the registry's emit-signal specs.

The two families were duplicated end to end — 6 model types, 6 read-model builders, 8 render functions, 4 execution types, all near-copies that differed only in a few nouns and the workflow-id namespace. They collapse into one generic family. Net change: about 225 fewer lines, across the dashboard's model, source, read-model, and renderer.

The intent is behaviour-preserving for the data — same numbers, same attribution. The deliberate UX changes: finding copy generalised to "findings"/"details", advisory tabs now order by registration (A11y before Determinism), and the global liveness count now includes the a11y loops it used to omit. Each is called out below.

One generic model family

AdvisoryLoop / AdvisoryRow / AdvisoryRecord replace the Review*/A11y* pairs. The row's per-loop detail (banned calls vs gap kinds) becomes one detail tuple; the record's total (hazards vs issues) becomes one findings count. AdvisoryView is the emit-signal mirror of the acting family's LoopView: one advisory loop's whole tab, its icon and title carried from the registry.

src/froot/dashboard/model.py · 508 lines
src/froot/dashboard/model.py508 lines · Python
⋯ 325 lines hidden (lines 1–325)
1"""The dashboard's view model — pure, frozen, fully derived.
2 
3These types are the shape the renderer projects to HTML. They carry *already
4computed* numbers (the aggregates live in :mod:`~froot.dashboard.read_model`),
5so the renderer is a dumb projection and every figure on the page is
6unit-tested apart from any I/O. Nothing here is persisted: a
7:class:`DashboardModel` is built fresh per request and discarded after the
8response (derive, never store).
9"""
10 
11from __future__ import annotations
12 
13from datetime import datetime
14 
15from froot.domain.base import Frozen
16 
17 
18class SourceHealth(Frozen):
19 """Whether one external truth answered this request.
20 
21 Attributes:
22 name: The source label (``github`` / ``temporal`` / ``clickhouse``).
23 ok: True when the source returned data; False when it errored or is off.
24 detail: A short human note — the row count, ``off``, or the error.
25 """
26 
27 name: str
28 ok: bool
29 detail: str
30 
31 
32class ScanLoop(Frozen):
33 """The liveness of one repo's durable scan schedule (the signal stage).
34 
35 Attributes:
36 repo: The ``owner/name`` slug this loop watches.
37 loop: Which maintenance loop this row is for (``dependency-patch`` /
38 ``security-patch``), so the two loops' scans show distinctly.
39 status: The current scan workflow status (``running`` /
40 ``continued_as_new`` / ``terminated`` / ``none`` / ...), lowercased.
41 live: True when the loop is actively self-scheduling (running / CAN).
42 last_tick: When the current scan execution started (≈ the last tick),
43 or ``None`` if no scan workflow exists for the repo.
44 """
45 
46 repo: str
47 loop: str = "dependency-patch"
48 status: str
49 live: bool
50 last_tick: datetime | None
51 
52 
53class BumpRow(Frozen):
54 """One proposed dependency bump, joined across GitHub and Temporal.
55 
56 GitHub is authoritative for the outcome (state / timestamps / PR); Temporal
57 enriches with the model verdict and the CI reading while it is still in the
58 7-day window, with the PR body as the durable fallback for the verdict.
59 
60 Attributes:
61 repo: The ``owner/name`` slug.
62 loop: Which loop proposed it (``dependency-patch`` /
63 ``security-patch``), from the PR's loop label — so the two loops'
64 records never mix.
65 package: The bumped dependency.
66 from_version: The version bumped from, if known.
67 to_version: The version bumped to.
68 state: The GitHub PR state — ``open`` / ``merged`` / ``closed``.
69 verdict: The changelog verdict (``clean`` / ``risky`` / ``unknown``), or
70 ``None`` when neither Temporal nor the PR body yields one.
71 ci: The terminal CI reading (``passed`` / ``failed`` / ``absent`` /
72 ``timed_out``), or ``None`` when only durable GitHub data remains.
73 pr_number: The PR number, if a PR exists.
74 pr_url: The PR URL, if a PR exists.
75 opened_at: When the PR was opened.
76 merged_at: When the PR was merged, if it was.
77 ttm_minutes: Time-to-merge in minutes (merged - opened), if merged.
78 age_hours: Age in hours for a still-open PR (now - opened), or ``None``.
79 would_auto_merge: For an open PR, whether it meets the earned-autonomy
80 grant — the loop's actual merge decision on an allowlisted repo, the
81 advisory shadow-gate verdict everywhere else (the default).
82 held_reason: Why an open PR would *not* auto-merge, the first blocker.
83 post_merge: For a merged PR, whether the merge *held* — ``held`` (the
84 branch's CI stayed green after the merge), ``broke`` (it went red),
85 ``reverted`` (a later commit reverted it), or ``None`` when no
86 post-merge signal is recoverable (the outcome leg, coarse).
87 """
88 
89 repo: str
90 loop: str = "dependency-patch"
91 package: str
92 from_version: str | None
93 to_version: str
94 state: str
95 verdict: str | None
96 ci: str | None
97 pr_number: int | None
98 pr_url: str | None
99 opened_at: datetime | None
100 merged_at: datetime | None
101 ttm_minutes: float | None
102 age_hours: float | None
103 would_auto_merge: bool = False
104 held_reason: str | None = None
105 post_merge: str | None = None
106 # The judgment environment (judge-model slug) the PR was opened under, or
107 # ``None`` if unstamped; the gate counts only the current env (§3.7).
108 env: str | None = None
109 
110 
111class ClassGate(Frozen):
112 """The earned-autonomy standing of one (repo, loop) class.
113 
114 The MHE economics of approval (§3.6) made legible: a class earns its gate
115 move with a high enough approval rate over enough recently-decided PRs,
116 and the budget framing shows what moving the gate would reclaim.
117 
118 Attributes:
119 repo: The ``owner/name`` slug.
120 loop: The loop this class is for.
121 decided: PRs decided (merged or closed) in the recent window.
122 merged: How many of those were merged.
123 merge_rate: ``merged / decided``, or ``None`` if none decided.
124 determined: Merges with a confirmed post-merge outcome in the window
125 (held / broke / reverted) — the defect bearing's evidence.
126 defects: Of those, how many broke the branch or were reverted.
127 defect_rate: ``defects / determined``, or ``None`` if none determined —
128 the second, independent bearing the gate triangulates against.
129 prior_env_decided: Decided PRs in the window earned under a *different*
130 (or no) environment — they no longer count, so this figure makes a
131 model-change reset legible rather than a mysterious drop to zero.
132 earned: Whether the class has earned its gate move under the policy.
133 blocker: Why it has not, if it has not (else ``None``).
134 approvals_per_week: The steward approvals this class costs now
135 (merges per week over the window) — the current budget draw.
136 reclaim_per_week: How many of those a gate move would auto-merge
137 (the clean-and-green ones), i.e. the budget reclaimed — ``0`` until
138 the class is ``earned``, since an un-earned class reclaims nothing.
139 window_days: The look-back window the figures cover.
140 """
141 
142 repo: str
143 loop: str
144 decided: int
145 merged: int
146 merge_rate: float | None
147 determined: int
148 defects: int
149 defect_rate: float | None
150 prior_env_decided: int
151 earned: bool
152 blocker: str | None
153 approvals_per_week: float
154 reclaim_per_week: float
155 window_days: int
156 
157 
158class Failure(Frozen):
159 """A bump loop that did not close — the honest friction signal.
160 
161 Attributes:
162 workflow_id: The Temporal workflow id (encodes repo/package/target).
163 kind: ``terminated`` or ``failed``.
164 reason: The human-readable termination/failure reason, if recovered.
165 when: When the workflow closed, if known.
166 """
167 
168 workflow_id: str
169 kind: str
170 reason: str | None
171 when: datetime | None
172 
173 
174class ActivityStat(Frozen):
175 """Latency of one activity stage, from traces (run-telemetry enrichment).
176 
177 Attributes:
178 name: The activity name (``scan_candidates``, ``open_pull_request``).
179 count: How many executions in the window.
180 avg_ms: Mean duration in milliseconds.
181 max_ms: Max duration in milliseconds.
182 """
183 
184 name: str
185 count: int
186 avg_ms: float
187 max_ms: float
188 
189 
190class RunTelemetry(Frozen):
191 """Trace-derived run telemetry from ClickHouse, or an unavailable marker.
192 
193 Attributes:
194 available: True when ClickHouse answered with froot traces.
195 total_spans: Total froot spans in the window.
196 error_spans: Spans that ended in an error.
197 last_activity: The most recent froot span timestamp.
198 window_days: The look-back window the figures cover.
199 activities: Per-stage latency rows.
200 """
201 
202 available: bool
203 total_spans: int
204 error_spans: int
205 last_activity: datetime | None
206 window_days: int
207 activities: tuple[ActivityStat, ...]
208 
209 
210class TrackRecord(Frozen):
211 """The reputation headline, derived from GitHub PR outcomes.
212 
213 Attributes:
214 opened: Total bumps froot has proposed.
215 merged: How many a human merged.
216 closed_unmerged: How many were closed without merging.
217 open_now: How many are still awaiting a decision.
218 merge_rate: ``merged / (merged + closed_unmerged)``, or ``None`` if none
219 have been decided yet.
220 median_ttm_minutes: Median time-to-merge across merged PRs, or ``None``.
221 """
222 
223 opened: int
224 merged: int
225 closed_unmerged: int
226 open_now: int
227 merge_rate: float | None
228 median_ttm_minutes: float | None
229 
230 
231class Verification(Frozen):
232 """The CI-oracle breakdown — kept honest about whether an oracle existed.
233 
234 Attributes:
235 passed: Bumps whose CI went green.
236 failed: Bumps whose CI went red.
237 absent: Bumps where no CI check existed (no oracle).
238 timed_out: Bumps where froot stopped waiting on CI.
239 unknown: Bumps with no recoverable CI reading (aged out of Temporal).
240 oracle_existed: Bumps where a real oracle reported (passed + failed).
241 with_reading: Bumps with any CI reading at all (excludes ``unknown``).
242 """
243 
244 passed: int
245 failed: int
246 absent: int
247 timed_out: int
248 unknown: int
249 oracle_existed: int
250 with_reading: int
251 
252 
253class Reliability(Frozen):
254 """Did the merges *hold*? — the post-merge outcome leg (coarse, low-recall).
255 
256 A merge is not the same as a success: the success leg is whether the merge
257 *held* once it landed. This breaks recent merges into held / broke /
258 reverted, with the honest caveat that it sees only what the default branch's
259 CI and git-reverts reveal — a manual or bundled revert is invisible, so the
260 defect rate is a *floor*, not the truth. It is the natural-traffic bearing
261 the adversarial canary leg exercises.
262 
263 Attributes:
264 held: Merges whose branch CI stayed green after the merge.
265 broke: Merges whose branch CI went red after the merge.
266 reverted: Merges a later commit git-reverted (the minority that are).
267 unverified: Merges with no recoverable post-merge signal (no branch
268 oracle, or aged past the commit window) — never counted as held.
269 determined: ``held + broke + reverted`` — merges actually classified.
270 defect_rate: ``(broke + reverted) / determined``, or ``None`` if none
271 were determined. A floor on the true defect rate, by construction.
272 window_days: The look-back window over merges considered.
273 """
274 
275 held: int
276 broke: int
277 reverted: int
278 unverified: int
279 determined: int
280 defect_rate: float | None
281 window_days: int
282 
283 
284class Probes(Frozen):
285 """Adversarial canary probes — does the guardrail still bite? (§2.11).
286 
287 A canary is a deliberately-bad bump planted to test the guardrail; a healthy
288 loop must refuse to merge it. These counts are kept **strictly apart** from
289 the genuine track record and defect rate — a synthetic failure must never
290 pollute the real bearings — and shown on their own. ``escaped > 0`` is the
291 alarm: a known-bad bump that landed means the guardrail has a hole.
292 
293 Attributes:
294 caught: Probes the guardrail refused (closed, or never landed).
295 escaped: Probes that merged anyway — a guardrail hole (should be 0).
296 pending: Probes still in flight (open, no verdict yet).
297 total: All canary probes seen.
298 """
299 
300 caught: int
301 escaped: int
302 pending: int
303 total: int
304 
305 
306class Judgment(Frozen):
307 """The model's changelog-verdict mix and its calibration against CI.
308 
309 Attributes:
310 clean: Verdicts of ``clean``.
311 risky: Verdicts of ``risky``.
312 unknown: Verdicts of ``unknown``.
313 none: Bumps with no recoverable verdict.
314 clean_but_failed: ``clean`` verdicts whose CI failed (mis-judged).
315 flagged_but_passed: ``risky``/``unknown`` verdicts whose CI passed.
316 """
317 
318 clean: int
319 risky: int
320 unknown: int
321 none: int
322 clean_but_failed: int
323 flagged_but_passed: int
324 
325 
326class AdvisoryLoop(Frozen):
327 """Liveness of one repo's advisory loop (the emit-signal family).
328 
329 The advisory family (determinism-review, a11y-review) scans open PRs and
330 leaves one decaying comment; this is the per-repo loop's heartbeat, the
331 same shape every advisory loop reports.
332 
333 Attributes:
334 repo: The ``owner/name`` slug this loop reviews.
335 status: The review workflow status (``running`` /
336 ``continued_as_new`` / ``terminated`` / ...), lowercased.
337 live: True when the loop is actively self-scheduling.
338 last_tick: When the current review execution started (≈ the last tick),
339 or ``None`` if no review workflow exists for the repo.
340 """
341 
342 repo: str
343 status: str
344 live: bool
345 last_tick: datetime | None
346 
347 
348class AdvisoryRow(Frozen):
349 """One per-PR advisory review, from its per-PR workflow's result.
350 
351 Attributes:
352 repo: The ``owner/name`` slug the PR belongs to.
353 pr_number: The reviewed PR number, if known.
354 pr_url: The PR's web URL, if it can be formed.
355 head_sha: The head commit the review ran against.
356 findings: How many findings the review surfaced.
357 detail: The distinct finding kinds flagged — banned calls for the
358 determinism loop (``datetime.datetime.now``…), gap kinds for a11y
359 (``missing-alt``…).
360 comment_url: The advisory comment's URL, if one was posted.
361 status: The review workflow status (``completed`` / ``running`` / ...).
362 reviewed_at: When the review closed, or started if still running.
363 """
364 
365 repo: str
366 pr_number: int | None
367 pr_url: str | None
368 head_sha: str | None
369 findings: int
370 detail: tuple[str, ...]
371 comment_url: str | None
372 status: str
373 reviewed_at: datetime | None
374 
375 
376class AdvisoryRecord(Frozen):
377 """One advisory loop's headline, derived from its completed reviews.
378 
379 Attributes:
380 reviewed: Completed per-PR reviews in the recent window.
381 flagged: Reviews that surfaced at least one finding.
382 clean: Reviews that surfaced none.
383 findings: Total findings surfaced across all reviews.
384 repos_covered: Distinct repos with a live loop.
385 """
386 
387 reviewed: int
388 flagged: int
389 clean: int
390 findings: int
391 repos_covered: int
392 
393 
394class AdvisoryView(Frozen):
395 """One advisory loop's complete tab — the same treatment for every loop.
396 
397 The acting family's :class:`LoopView`, mirrored for the emit-signal family.
398 The presentation (icon, title) is the loop's registered spec, derived once
399 in the read-model, so a new advisory loop's tab appears with no renderer
400 change. Built by partitioning the advisory executions by loop and running
401 the same aggregates for each.
402 
403 Attributes:
404 loop: The loop key (``determinism-review`` / ``a11y-review``).
405 icon: The tab's icon key, from the loop's registered spec.
406 title: The tab/panel title, from the loop's registered spec.
407 interval_seconds: This loop's poll cadence (telemetry-in-context).
408 loops: Liveness of this loop's per-repo review schedules.
409 record: This loop's headline over completed reviews.
410 rows: This loop's per-PR reviews, newest first.
411 """
412 
413 loop: str
414 icon: str
415 title: str
416 interval_seconds: int
417 loops: tuple[AdvisoryLoop, ...]
418 record: AdvisoryRecord
419 rows: tuple[AdvisoryRow, ...]
⋯ 89 lines hidden (lines 420–508)
420 
421 
422class LoopView(Frozen):
423 """One bump loop's complete standing — the same treatment for every loop.
424 
425 dependency-patch and security-patch are distinct trust classes (§3.9) that
426 never share a record, so each gets its own self-contained view: its gate and
427 four bearings, its queue, its detail. Built by partitioning the bump rows by
428 loop and running the same aggregates the combined view uses, so a loop's tab
429 is exactly the combined dashboard scoped to that one loop.
430 
431 Attributes:
432 loop: The loop key (``dependency-patch`` / ``security-patch``).
433 title: The human title for the tab.
434 icon: The tab's icon key, from the loop's registered spec.
435 scan_loops: Liveness of this loop's per-repo scan schedules.
436 scan_interval_seconds: This loop's scan cadence (telemetry-in-context).
437 track_record: This loop's reputation headline.
438 class_gates: This loop's per-repo earned-autonomy standing (the gate).
439 verification: This loop's CI-oracle breakdown.
440 reliability: This loop's post-merge outcome leg.
441 probes: This loop's adversarial canary tally.
442 judgment: This loop's model-verdict mix and calibration.
443 gate: This loop's open PRs awaiting a human, freshest last.
444 bumps: This loop's proposed bumps, newest first.
445 failures: This loop's bump loops that did not close.
446 """
447 
448 loop: str
449 title: str
450 icon: str
451 scan_loops: tuple[ScanLoop, ...]
452 scan_interval_seconds: int
453 track_record: TrackRecord
454 class_gates: tuple[ClassGate, ...]
455 verification: Verification
456 reliability: Reliability
457 probes: Probes
458 judgment: Judgment
459 gate: tuple[BumpRow, ...]
460 bumps: tuple[BumpRow, ...]
461 failures: tuple[Failure, ...]
462 
463 
464class DashboardModel(Frozen):
465 """The whole 10,000ft view, fully derived and ready to render.
466 
467 Attributes:
468 generated_at: When this view was assembled (UTC).
469 repos_configured: The repos froot is pointed at (``FROOT_REPOS``).
470 scan_interval_seconds: The configured gap between scan ticks.
471 sources: Per-source health for this request.
472 scan_loops: Liveness of each repo's scan schedule.
473 track_record: The reputation headline.
474 class_gates: The earned-autonomy standing per (repo, loop) — the gate.
475 verification: The CI-oracle breakdown.
476 reliability: Did the merges hold post-merge (the outcome leg, coarse).
477 probes: Adversarial canary results, kept apart from the real bearings.
478 judgment: The model-verdict mix and calibration.
479 gate: Open PRs awaiting a human, the freshest last.
480 bumps: Every proposed bump, newest first (the detail behind the stats).
481 failures: Bump loops that did not close.
482 advisory: One self-contained view per advisory loop — the emit-signal
483 tabs (determinism-review, a11y-review), each with its own
484 heartbeat, headline, and per-PR reviews. Derived from the registry,
485 so a new advisory loop is one more view here.
486 telemetry: Trace-derived run telemetry (best-effort).
487 bump_loops: One self-contained view per bump loop — the per-loop tabs.
488 The top-level bump aggregates above are the combined ("all loops")
489 view; these split it per trust class.
490 """
491 
492 generated_at: datetime
493 repos_configured: tuple[str, ...]
494 scan_interval_seconds: int
495 sources: tuple[SourceHealth, ...]
496 scan_loops: tuple[ScanLoop, ...]
497 track_record: TrackRecord
498 class_gates: tuple[ClassGate, ...]
499 verification: Verification
500 reliability: Reliability
501 probes: Probes
502 judgment: Judgment
503 gate: tuple[BumpRow, ...]
504 bumps: tuple[BumpRow, ...]
505 failures: tuple[Failure, ...]
506 advisory: tuple[AdvisoryView, ...]
507 telemetry: RunTelemetry
508 bump_loops: tuple[LoopView, ...] = ()

DashboardModel drops its eight flat review_*/a11y_* fields for one advisory tuple — a new advisory loop is one more view here, nothing else.

src/froot/dashboard/model.py · 508 lines
src/froot/dashboard/model.py508 lines · Python
⋯ 497 lines hidden (lines 1–497)
1"""The dashboard's view model — pure, frozen, fully derived.
2 
3These types are the shape the renderer projects to HTML. They carry *already
4computed* numbers (the aggregates live in :mod:`~froot.dashboard.read_model`),
5so the renderer is a dumb projection and every figure on the page is
6unit-tested apart from any I/O. Nothing here is persisted: a
7:class:`DashboardModel` is built fresh per request and discarded after the
8response (derive, never store).
9"""
10 
11from __future__ import annotations
12 
13from datetime import datetime
14 
15from froot.domain.base import Frozen
16 
17 
18class SourceHealth(Frozen):
19 """Whether one external truth answered this request.
20 
21 Attributes:
22 name: The source label (``github`` / ``temporal`` / ``clickhouse``).
23 ok: True when the source returned data; False when it errored or is off.
24 detail: A short human note — the row count, ``off``, or the error.
25 """
26 
27 name: str
28 ok: bool
29 detail: str
30 
31 
32class ScanLoop(Frozen):
33 """The liveness of one repo's durable scan schedule (the signal stage).
34 
35 Attributes:
36 repo: The ``owner/name`` slug this loop watches.
37 loop: Which maintenance loop this row is for (``dependency-patch`` /
38 ``security-patch``), so the two loops' scans show distinctly.
39 status: The current scan workflow status (``running`` /
40 ``continued_as_new`` / ``terminated`` / ``none`` / ...), lowercased.
41 live: True when the loop is actively self-scheduling (running / CAN).
42 last_tick: When the current scan execution started (≈ the last tick),
43 or ``None`` if no scan workflow exists for the repo.
44 """
45 
46 repo: str
47 loop: str = "dependency-patch"
48 status: str
49 live: bool
50 last_tick: datetime | None
51 
52 
53class BumpRow(Frozen):
54 """One proposed dependency bump, joined across GitHub and Temporal.
55 
56 GitHub is authoritative for the outcome (state / timestamps / PR); Temporal
57 enriches with the model verdict and the CI reading while it is still in the
58 7-day window, with the PR body as the durable fallback for the verdict.
59 
60 Attributes:
61 repo: The ``owner/name`` slug.
62 loop: Which loop proposed it (``dependency-patch`` /
63 ``security-patch``), from the PR's loop label — so the two loops'
64 records never mix.
65 package: The bumped dependency.
66 from_version: The version bumped from, if known.
67 to_version: The version bumped to.
68 state: The GitHub PR state — ``open`` / ``merged`` / ``closed``.
69 verdict: The changelog verdict (``clean`` / ``risky`` / ``unknown``), or
70 ``None`` when neither Temporal nor the PR body yields one.
71 ci: The terminal CI reading (``passed`` / ``failed`` / ``absent`` /
72 ``timed_out``), or ``None`` when only durable GitHub data remains.
73 pr_number: The PR number, if a PR exists.
74 pr_url: The PR URL, if a PR exists.
75 opened_at: When the PR was opened.
76 merged_at: When the PR was merged, if it was.
77 ttm_minutes: Time-to-merge in minutes (merged - opened), if merged.
78 age_hours: Age in hours for a still-open PR (now - opened), or ``None``.
79 would_auto_merge: For an open PR, whether it meets the earned-autonomy
80 grant — the loop's actual merge decision on an allowlisted repo, the
81 advisory shadow-gate verdict everywhere else (the default).
82 held_reason: Why an open PR would *not* auto-merge, the first blocker.
83 post_merge: For a merged PR, whether the merge *held* — ``held`` (the
84 branch's CI stayed green after the merge), ``broke`` (it went red),
85 ``reverted`` (a later commit reverted it), or ``None`` when no
86 post-merge signal is recoverable (the outcome leg, coarse).
87 """
88 
89 repo: str
90 loop: str = "dependency-patch"
91 package: str
92 from_version: str | None
93 to_version: str
94 state: str
95 verdict: str | None
96 ci: str | None
97 pr_number: int | None
98 pr_url: str | None
99 opened_at: datetime | None
100 merged_at: datetime | None
101 ttm_minutes: float | None
102 age_hours: float | None
103 would_auto_merge: bool = False
104 held_reason: str | None = None
105 post_merge: str | None = None
106 # The judgment environment (judge-model slug) the PR was opened under, or
107 # ``None`` if unstamped; the gate counts only the current env (§3.7).
108 env: str | None = None
109 
110 
111class ClassGate(Frozen):
112 """The earned-autonomy standing of one (repo, loop) class.
113 
114 The MHE economics of approval (§3.6) made legible: a class earns its gate
115 move with a high enough approval rate over enough recently-decided PRs,
116 and the budget framing shows what moving the gate would reclaim.
117 
118 Attributes:
119 repo: The ``owner/name`` slug.
120 loop: The loop this class is for.
121 decided: PRs decided (merged or closed) in the recent window.
122 merged: How many of those were merged.
123 merge_rate: ``merged / decided``, or ``None`` if none decided.
124 determined: Merges with a confirmed post-merge outcome in the window
125 (held / broke / reverted) — the defect bearing's evidence.
126 defects: Of those, how many broke the branch or were reverted.
127 defect_rate: ``defects / determined``, or ``None`` if none determined —
128 the second, independent bearing the gate triangulates against.
129 prior_env_decided: Decided PRs in the window earned under a *different*
130 (or no) environment — they no longer count, so this figure makes a
131 model-change reset legible rather than a mysterious drop to zero.
132 earned: Whether the class has earned its gate move under the policy.
133 blocker: Why it has not, if it has not (else ``None``).
134 approvals_per_week: The steward approvals this class costs now
135 (merges per week over the window) — the current budget draw.
136 reclaim_per_week: How many of those a gate move would auto-merge
137 (the clean-and-green ones), i.e. the budget reclaimed — ``0`` until
138 the class is ``earned``, since an un-earned class reclaims nothing.
139 window_days: The look-back window the figures cover.
140 """
141 
142 repo: str
143 loop: str
144 decided: int
145 merged: int
146 merge_rate: float | None
147 determined: int
148 defects: int
149 defect_rate: float | None
150 prior_env_decided: int
151 earned: bool
152 blocker: str | None
153 approvals_per_week: float
154 reclaim_per_week: float
155 window_days: int
156 
157 
158class Failure(Frozen):
159 """A bump loop that did not close — the honest friction signal.
160 
161 Attributes:
162 workflow_id: The Temporal workflow id (encodes repo/package/target).
163 kind: ``terminated`` or ``failed``.
164 reason: The human-readable termination/failure reason, if recovered.
165 when: When the workflow closed, if known.
166 """
167 
168 workflow_id: str
169 kind: str
170 reason: str | None
171 when: datetime | None
172 
173 
174class ActivityStat(Frozen):
175 """Latency of one activity stage, from traces (run-telemetry enrichment).
176 
177 Attributes:
178 name: The activity name (``scan_candidates``, ``open_pull_request``).
179 count: How many executions in the window.
180 avg_ms: Mean duration in milliseconds.
181 max_ms: Max duration in milliseconds.
182 """
183 
184 name: str
185 count: int
186 avg_ms: float
187 max_ms: float
188 
189 
190class RunTelemetry(Frozen):
191 """Trace-derived run telemetry from ClickHouse, or an unavailable marker.
192 
193 Attributes:
194 available: True when ClickHouse answered with froot traces.
195 total_spans: Total froot spans in the window.
196 error_spans: Spans that ended in an error.
197 last_activity: The most recent froot span timestamp.
198 window_days: The look-back window the figures cover.
199 activities: Per-stage latency rows.
200 """
201 
202 available: bool
203 total_spans: int
204 error_spans: int
205 last_activity: datetime | None
206 window_days: int
207 activities: tuple[ActivityStat, ...]
208 
209 
210class TrackRecord(Frozen):
211 """The reputation headline, derived from GitHub PR outcomes.
212 
213 Attributes:
214 opened: Total bumps froot has proposed.
215 merged: How many a human merged.
216 closed_unmerged: How many were closed without merging.
217 open_now: How many are still awaiting a decision.
218 merge_rate: ``merged / (merged + closed_unmerged)``, or ``None`` if none
219 have been decided yet.
220 median_ttm_minutes: Median time-to-merge across merged PRs, or ``None``.
221 """
222 
223 opened: int
224 merged: int
225 closed_unmerged: int
226 open_now: int
227 merge_rate: float | None
228 median_ttm_minutes: float | None
229 
230 
231class Verification(Frozen):
232 """The CI-oracle breakdown — kept honest about whether an oracle existed.
233 
234 Attributes:
235 passed: Bumps whose CI went green.
236 failed: Bumps whose CI went red.
237 absent: Bumps where no CI check existed (no oracle).
238 timed_out: Bumps where froot stopped waiting on CI.
239 unknown: Bumps with no recoverable CI reading (aged out of Temporal).
240 oracle_existed: Bumps where a real oracle reported (passed + failed).
241 with_reading: Bumps with any CI reading at all (excludes ``unknown``).
242 """
243 
244 passed: int
245 failed: int
246 absent: int
247 timed_out: int
248 unknown: int
249 oracle_existed: int
250 with_reading: int
251 
252 
253class Reliability(Frozen):
254 """Did the merges *hold*? — the post-merge outcome leg (coarse, low-recall).
255 
256 A merge is not the same as a success: the success leg is whether the merge
257 *held* once it landed. This breaks recent merges into held / broke /
258 reverted, with the honest caveat that it sees only what the default branch's
259 CI and git-reverts reveal — a manual or bundled revert is invisible, so the
260 defect rate is a *floor*, not the truth. It is the natural-traffic bearing
261 the adversarial canary leg exercises.
262 
263 Attributes:
264 held: Merges whose branch CI stayed green after the merge.
265 broke: Merges whose branch CI went red after the merge.
266 reverted: Merges a later commit git-reverted (the minority that are).
267 unverified: Merges with no recoverable post-merge signal (no branch
268 oracle, or aged past the commit window) — never counted as held.
269 determined: ``held + broke + reverted`` — merges actually classified.
270 defect_rate: ``(broke + reverted) / determined``, or ``None`` if none
271 were determined. A floor on the true defect rate, by construction.
272 window_days: The look-back window over merges considered.
273 """
274 
275 held: int
276 broke: int
277 reverted: int
278 unverified: int
279 determined: int
280 defect_rate: float | None
281 window_days: int
282 
283 
284class Probes(Frozen):
285 """Adversarial canary probes — does the guardrail still bite? (§2.11).
286 
287 A canary is a deliberately-bad bump planted to test the guardrail; a healthy
288 loop must refuse to merge it. These counts are kept **strictly apart** from
289 the genuine track record and defect rate — a synthetic failure must never
290 pollute the real bearings — and shown on their own. ``escaped > 0`` is the
291 alarm: a known-bad bump that landed means the guardrail has a hole.
292 
293 Attributes:
294 caught: Probes the guardrail refused (closed, or never landed).
295 escaped: Probes that merged anyway — a guardrail hole (should be 0).
296 pending: Probes still in flight (open, no verdict yet).
297 total: All canary probes seen.
298 """
299 
300 caught: int
301 escaped: int
302 pending: int
303 total: int
304 
305 
306class Judgment(Frozen):
307 """The model's changelog-verdict mix and its calibration against CI.
308 
309 Attributes:
310 clean: Verdicts of ``clean``.
311 risky: Verdicts of ``risky``.
312 unknown: Verdicts of ``unknown``.
313 none: Bumps with no recoverable verdict.
314 clean_but_failed: ``clean`` verdicts whose CI failed (mis-judged).
315 flagged_but_passed: ``risky``/``unknown`` verdicts whose CI passed.
316 """
317 
318 clean: int
319 risky: int
320 unknown: int
321 none: int
322 clean_but_failed: int
323 flagged_but_passed: int
324 
325 
326class AdvisoryLoop(Frozen):
327 """Liveness of one repo's advisory loop (the emit-signal family).
328 
329 The advisory family (determinism-review, a11y-review) scans open PRs and
330 leaves one decaying comment; this is the per-repo loop's heartbeat, the
331 same shape every advisory loop reports.
332 
333 Attributes:
334 repo: The ``owner/name`` slug this loop reviews.
335 status: The review workflow status (``running`` /
336 ``continued_as_new`` / ``terminated`` / ...), lowercased.
337 live: True when the loop is actively self-scheduling.
338 last_tick: When the current review execution started (≈ the last tick),
339 or ``None`` if no review workflow exists for the repo.
340 """
341 
342 repo: str
343 status: str
344 live: bool
345 last_tick: datetime | None
346 
347 
348class AdvisoryRow(Frozen):
349 """One per-PR advisory review, from its per-PR workflow's result.
350 
351 Attributes:
352 repo: The ``owner/name`` slug the PR belongs to.
353 pr_number: The reviewed PR number, if known.
354 pr_url: The PR's web URL, if it can be formed.
355 head_sha: The head commit the review ran against.
356 findings: How many findings the review surfaced.
357 detail: The distinct finding kinds flagged — banned calls for the
358 determinism loop (``datetime.datetime.now``…), gap kinds for a11y
359 (``missing-alt``…).
360 comment_url: The advisory comment's URL, if one was posted.
361 status: The review workflow status (``completed`` / ``running`` / ...).
362 reviewed_at: When the review closed, or started if still running.
363 """
364 
365 repo: str
366 pr_number: int | None
367 pr_url: str | None
368 head_sha: str | None
369 findings: int
370 detail: tuple[str, ...]
371 comment_url: str | None
372 status: str
373 reviewed_at: datetime | None
374 
375 
376class AdvisoryRecord(Frozen):
377 """One advisory loop's headline, derived from its completed reviews.
378 
379 Attributes:
380 reviewed: Completed per-PR reviews in the recent window.
381 flagged: Reviews that surfaced at least one finding.
382 clean: Reviews that surfaced none.
383 findings: Total findings surfaced across all reviews.
384 repos_covered: Distinct repos with a live loop.
385 """
386 
387 reviewed: int
388 flagged: int
389 clean: int
390 findings: int
391 repos_covered: int
392 
393 
394class AdvisoryView(Frozen):
395 """One advisory loop's complete tab — the same treatment for every loop.
396 
397 The acting family's :class:`LoopView`, mirrored for the emit-signal family.
398 The presentation (icon, title) is the loop's registered spec, derived once
399 in the read-model, so a new advisory loop's tab appears with no renderer
400 change. Built by partitioning the advisory executions by loop and running
401 the same aggregates for each.
402 
403 Attributes:
404 loop: The loop key (``determinism-review`` / ``a11y-review``).
405 icon: The tab's icon key, from the loop's registered spec.
406 title: The tab/panel title, from the loop's registered spec.
407 interval_seconds: This loop's poll cadence (telemetry-in-context).
408 loops: Liveness of this loop's per-repo review schedules.
409 record: This loop's headline over completed reviews.
410 rows: This loop's per-PR reviews, newest first.
411 """
412 
413 loop: str
414 icon: str
415 title: str
416 interval_seconds: int
417 loops: tuple[AdvisoryLoop, ...]
418 record: AdvisoryRecord
419 rows: tuple[AdvisoryRow, ...]
420 
421 
422class LoopView(Frozen):
423 """One bump loop's complete standing — the same treatment for every loop.
424 
425 dependency-patch and security-patch are distinct trust classes (§3.9) that
426 never share a record, so each gets its own self-contained view: its gate and
427 four bearings, its queue, its detail. Built by partitioning the bump rows by
428 loop and running the same aggregates the combined view uses, so a loop's tab
429 is exactly the combined dashboard scoped to that one loop.
430 
431 Attributes:
432 loop: The loop key (``dependency-patch`` / ``security-patch``).
433 title: The human title for the tab.
434 icon: The tab's icon key, from the loop's registered spec.
435 scan_loops: Liveness of this loop's per-repo scan schedules.
436 scan_interval_seconds: This loop's scan cadence (telemetry-in-context).
437 track_record: This loop's reputation headline.
438 class_gates: This loop's per-repo earned-autonomy standing (the gate).
439 verification: This loop's CI-oracle breakdown.
440 reliability: This loop's post-merge outcome leg.
441 probes: This loop's adversarial canary tally.
442 judgment: This loop's model-verdict mix and calibration.
443 gate: This loop's open PRs awaiting a human, freshest last.
444 bumps: This loop's proposed bumps, newest first.
445 failures: This loop's bump loops that did not close.
446 """
447 
448 loop: str
449 title: str
450 icon: str
451 scan_loops: tuple[ScanLoop, ...]
452 scan_interval_seconds: int
453 track_record: TrackRecord
454 class_gates: tuple[ClassGate, ...]
455 verification: Verification
456 reliability: Reliability
457 probes: Probes
458 judgment: Judgment
459 gate: tuple[BumpRow, ...]
460 bumps: tuple[BumpRow, ...]
461 failures: tuple[Failure, ...]
462 
463 
464class DashboardModel(Frozen):
465 """The whole 10,000ft view, fully derived and ready to render.
466 
467 Attributes:
468 generated_at: When this view was assembled (UTC).
469 repos_configured: The repos froot is pointed at (``FROOT_REPOS``).
470 scan_interval_seconds: The configured gap between scan ticks.
471 sources: Per-source health for this request.
472 scan_loops: Liveness of each repo's scan schedule.
473 track_record: The reputation headline.
474 class_gates: The earned-autonomy standing per (repo, loop) — the gate.
475 verification: The CI-oracle breakdown.
476 reliability: Did the merges hold post-merge (the outcome leg, coarse).
477 probes: Adversarial canary results, kept apart from the real bearings.
478 judgment: The model-verdict mix and calibration.
479 gate: Open PRs awaiting a human, the freshest last.
480 bumps: Every proposed bump, newest first (the detail behind the stats).
481 failures: Bump loops that did not close.
482 advisory: One self-contained view per advisory loop — the emit-signal
483 tabs (determinism-review, a11y-review), each with its own
484 heartbeat, headline, and per-PR reviews. Derived from the registry,
485 so a new advisory loop is one more view here.
486 telemetry: Trace-derived run telemetry (best-effort).
487 bump_loops: One self-contained view per bump loop — the per-loop tabs.
488 The top-level bump aggregates above are the combined ("all loops")
489 view; these split it per trust class.
490 """
491 
492 generated_at: datetime
493 repos_configured: tuple[str, ...]
494 scan_interval_seconds: int
495 sources: tuple[SourceHealth, ...]
496 scan_loops: tuple[ScanLoop, ...]
497 track_record: TrackRecord
498 class_gates: tuple[ClassGate, ...]
499 verification: Verification
500 reliability: Reliability
501 probes: Probes
502 judgment: Judgment
503 gate: tuple[BumpRow, ...]
504 bumps: tuple[BumpRow, ...]
505 failures: tuple[Failure, ...]
506 advisory: tuple[AdvisoryView, ...]
507 telemetry: RunTelemetry
⋯ 1 line hidden (lines 508–508)
508 bump_loops: tuple[LoopView, ...] = ()

One execution stream, tagged by loop

The four execution types become two — AdvisoryExecution (the per-repo heartbeat) and PrAdvisoryExecution (the per-PR review) — each tagged with the loop it belongs to, so the read-model can partition the one stream back per loop.

src/froot/dashboard/temporal_source.py · 366 lines
src/froot/dashboard/temporal_source.py366 lines · Python
⋯ 71 lines hidden (lines 1–71)
1"""Temporal reader: the live run ledger (scan loops + bump outcomes).
2 
3Reuses the worker's own connected client. Lists the durable scan loops (the
4signal stage's heartbeat) and the per-bump workflows; for a completed bump it
5reads the structured outcome from the workflow result (verdict + CI reading + PR
6number), and for a terminated/failed one it recovers the human reason from
7history. Everything is keyed off the deterministic workflow ids, so the
8read-model joins to GitHub by PR number and to repos by id prefix without
9parsing ambiguous slugs. Temporal keeps ~7 days, so this is the *recent* ledger;
10GitHub is the durable one.
11 
12Never raises: a failure returns an error string and whatever was gathered.
13"""
14 
15from __future__ import annotations
16 
17import re
18from datetime import datetime
19from typing import TYPE_CHECKING, Any, Final
20 
21from temporalio.client import WorkflowExecutionStatus
22 
23from froot.domain.base import Frozen
24from froot.domain.loop import Loop
25 
26if TYPE_CHECKING:
27 from collections.abc import AsyncIterator
28 
29 from temporalio.client import Client, WorkflowExecution
30 
31# A backstop so a runaway visibility store can never spin this forever.
32_MAX_PER_TYPE: Final = 500
33# The owner/name slug out of a PR url — the repo-aware join key (see fetch).
34_PR_URL: Final = re.compile(r"github\.com/([^/]+/[^/]+)/pull/\d+")
35# The bespoke per-advisory-loop runtime the source reads: which workflow types
36# carry each loop's heartbeat and per-PR reviews, and the result key naming each
37# finding's kind. A new advisory loop adds one row (its workflows stay its own);
38# everything downstream of here is generic.
39_ADVISORY_SOURCES: Final = (
40 (Loop.DETERMINISM_REVIEW, "ReviewWorkflow", "PrReviewWorkflow", "rule"),
41 (Loop.A11Y_REVIEW, "A11yReviewWorkflow", "PrA11yReviewWorkflow", "kind"),
43 
44 
45class ScanExecution(Frozen):
46 """One scan-loop execution (there may be several per id across CAN)."""
47 
48 workflow_id: str
49 status: str
50 start: datetime | None
51 
52 
53class BumpExecution(Frozen):
54 """One bump workflow, with its outcome (if completed) or reason (if not).
55 
56 ``repo`` is the ``owner/name`` the PR was opened against, parsed from the
57 outcome's PR url; it makes the GitHub join repo-aware so two repos' PRs that
58 share a number cannot cross-attribute.
59 """
60 
61 workflow_id: str
62 status: str
63 start: datetime | None
64 close: datetime | None
65 verdict: str | None
66 ci: str | None
67 pr_number: int | None
68 repo: str | None
69 reason: str | None
70 
71 
72class AdvisoryExecution(Frozen):
73 """One advisory-loop execution (several per id across CAN).
74 
75 ``loop`` tags which advisory loop (``determinism-review`` /
76 ``a11y-review``) this heartbeat belongs to, so the read-model partitions
77 the one stream back into per-loop tabs.
78 """
79 
80 loop: str
81 workflow_id: str
82 status: str
83 start: datetime | None
84 
85 
86class PrAdvisoryExecution(Frozen):
87 """One per-PR advisory review, with its result (if completed).
88 
89 The finding count + distinct finding kinds + advisory comment come from the
90 per-PR workflow result; the repo is recovered by the read-model from the
91 deterministic workflow id (it encodes the slug). ``loop`` tags the family.
92 """
93 
94 loop: str
95 workflow_id: str
96 status: str
97 start: datetime | None
98 close: datetime | None
99 pr_number: int | None
⋯ 267 lines hidden (lines 100–366)
100 head_sha: str | None
101 findings: int
102 detail: tuple[str, ...]
103 comment_url: str | None
104 
105 
106def _status(execution: WorkflowExecution) -> str:
107 """Lowercase the Temporal status enum (``continued_as_new`` etc.)."""
108 status = execution.status
109 return status.name.lower() if status is not None else "unknown"
110 
111 
112def _as_dict(result: object) -> dict[str, Any] | None:
113 """Normalise a decoded workflow result to a dict (converter-agnostic)."""
114 if isinstance(result, dict):
115 return result
116 dump = getattr(result, "model_dump", None)
117 if callable(dump):
118 dumped = dump(mode="json")
119 return dumped if isinstance(dumped, dict) else None
120 return None
121 
122 
123def _nested_kind(data: dict[str, Any], key: str) -> str | None:
124 """Read ``data[key]["kind"]`` defensively (a discriminated-union tag)."""
125 section = data.get(key)
126 if isinstance(section, dict):
127 kind = section.get("kind")
128 if isinstance(kind, str):
129 return kind
130 return None
131 
132 
133def _pr_number(data: dict[str, Any]) -> int | None:
134 """Read ``data["pr"]["number"]`` defensively."""
135 pr = data.get("pr")
136 if isinstance(pr, dict):
137 number = pr.get("number")
138 if isinstance(number, int):
139 return number
140 return None
141 
142 
143def _pr_repo(data: dict[str, Any]) -> str | None:
144 """The ``owner/name`` slug from ``data["pr"]["url"]`` defensively."""
145 pr = data.get("pr")
146 url = pr.get("url") if isinstance(pr, dict) else None
147 if isinstance(url, str):
148 match = _PR_URL.search(url)
149 if match is not None:
150 return match.group(1)
151 return None
152 
153 
154class _Outcome(Frozen):
155 """The bits of a completed bump's outcome the read-model joins on."""
156 
157 verdict: str | None
158 ci: str | None
159 pr_number: int | None
160 repo: str | None
161 
162 
163async def _outcome(client: Client, execution: WorkflowExecution) -> _Outcome:
164 """A completed bump's outcome (verdict/ci/pr/repo), or all ``None``."""
165 empty = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
166 try:
167 handle = client.get_workflow_handle(
168 execution.id, run_id=execution.run_id
169 )
170 data = _as_dict(await handle.result())
171 except Exception: # best-effort enrichment — a bad decode is never fatal
172 return empty
173 if data is None:
174 return empty
175 return _Outcome(
176 verdict=_nested_kind(data, "verdict"),
177 ci=_nested_kind(data, "ci"),
178 pr_number=_pr_number(data),
179 repo=_pr_repo(data),
180 )
181 
182 
183async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
184 """Recover a terminated/failed bump's human reason from history."""
185 try:
186 handle = client.get_workflow_handle(
187 execution.id, run_id=execution.run_id
188 )
189 found: str | None = None
190 async for event in handle.fetch_history_events():
191 terminated = event.workflow_execution_terminated_event_attributes
192 if terminated.reason:
193 found = terminated.reason
194 failed = event.workflow_execution_failed_event_attributes
195 if failed.failure.message:
196 found = failed.failure.message
197 return found
198 except Exception: # the reason is a nicety — never fail the page for it
199 return None
200 
201 
202class _AdvisoryOutcome(Frozen):
203 """The completed-review bits the read-model joins on (any advisory loop)."""
204 
205 pr_number: int | None
206 head_sha: str | None
207 findings: int
208 detail: tuple[str, ...]
209 comment_url: str | None
210 
211 
212async def _advisory_outcome(
213 client: Client, execution: WorkflowExecution, detail_key: str
214) -> _AdvisoryOutcome:
215 """A completed review's result (pr/head/findings/detail/comment).
216 
217 ``detail_key`` names the field on each finding carrying its kind — the
218 banned call (``rule``) for determinism, the gap kind (``kind``) for a11y —
219 so one reader serves every advisory loop.
220 """
221 empty = _AdvisoryOutcome(
222 pr_number=None, head_sha=None, findings=0, detail=(), comment_url=None
223 )
224 try:
225 handle = client.get_workflow_handle(
226 execution.id, run_id=execution.run_id
227 )
228 data = _as_dict(await handle.result())
229 except Exception: # best-effort enrichment — a bad decode is never fatal
230 return empty
231 if data is None:
232 return empty
233 raw = data.get("findings")
234 findings = raw if isinstance(raw, list) else []
235 detail = tuple(
236 str(f[detail_key])
237 for f in findings
238 if isinstance(f, dict) and isinstance(f.get(detail_key), str)
239 )
240 number = data.get("pr_number")
241 head = data.get("head_sha")
242 comment = data.get("comment_url")
243 return _AdvisoryOutcome(
244 pr_number=number if isinstance(number, int) else None,
245 head_sha=head if isinstance(head, str) else None,
246 findings=len(findings),
247 detail=detail,
248 comment_url=comment if isinstance(comment, str) else None,
249 )
250 
251 
252type _Ledger = tuple[
253 tuple[ScanExecution, ...],
254 tuple[BumpExecution, ...],
255 tuple[AdvisoryExecution, ...],
256 tuple[PrAdvisoryExecution, ...],
258 
259 
260async def fetch(client: Client) -> tuple[_Ledger, str | None]:
261 """Read froot's scan/bump + review executions (degrades to an error)."""
262 scans: list[ScanExecution] = []
263 bumps: list[BumpExecution] = []
264 advisory: list[AdvisoryExecution] = []
265 pr_advisory: list[PrAdvisoryExecution] = []
266 
267 def ledger() -> _Ledger:
268 return (
269 tuple(scans),
270 tuple(bumps),
271 tuple(advisory),
272 tuple(pr_advisory),
273 )
274 
275 try:
276 async for execution in _take(
277 client.list_workflows("WorkflowType = 'ScanWorkflow'")
278 ):
279 scans.append(
280 ScanExecution(
281 workflow_id=execution.id,
282 status=_status(execution),
283 start=execution.start_time,
284 )
285 )
286 async for execution in _take(
287 client.list_workflows("WorkflowType = 'BumpWorkflow'")
288 ):
289 status = _status(execution)
290 outcome = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
291 reason: str | None = None
292 if execution.status is WorkflowExecutionStatus.COMPLETED:
293 outcome = await _outcome(client, execution)
294 elif execution.status in (
295 WorkflowExecutionStatus.TERMINATED,
296 WorkflowExecutionStatus.FAILED,
297 ):
298 reason = await _reason(client, execution)
299 bumps.append(
300 BumpExecution(
301 workflow_id=execution.id,
302 status=status,
303 start=execution.start_time,
304 close=execution.close_time,
305 verdict=outcome.verdict,
306 ci=outcome.ci,
307 pr_number=outcome.pr_number,
308 repo=outcome.repo,
309 reason=reason,
310 )
311 )
312 for loop, repo_type, pr_type, detail_key in _ADVISORY_SOURCES:
313 async for execution in _take(
314 client.list_workflows(f"WorkflowType = '{repo_type}'")
315 ):
316 advisory.append(
317 AdvisoryExecution(
318 loop=loop,
319 workflow_id=execution.id,
320 status=_status(execution),
321 start=execution.start_time,
322 )
323 )
324 async for execution in _take(
325 client.list_workflows(f"WorkflowType = '{pr_type}'")
326 ):
327 review = _AdvisoryOutcome(
328 pr_number=None,
329 head_sha=None,
330 findings=0,
331 detail=(),
332 comment_url=None,
333 )
334 if execution.status is WorkflowExecutionStatus.COMPLETED:
335 review = await _advisory_outcome(
336 client, execution, detail_key
337 )
338 pr_advisory.append(
339 PrAdvisoryExecution(
340 loop=loop,
341 workflow_id=execution.id,
342 status=_status(execution),
343 start=execution.start_time,
344 close=execution.close_time,
345 pr_number=review.pr_number,
346 head_sha=review.head_sha,
347 findings=review.findings,
348 detail=review.detail,
349 comment_url=review.comment_url,
350 )
351 )
352 except Exception as exc: # degrade to an error string, never crash the page
353 return ledger(), f"{type(exc).__name__}: {exc}"
354 return ledger(), None
355 
356 
357async def _take(
358 iterator: AsyncIterator[WorkflowExecution],
359) -> AsyncIterator[WorkflowExecution]:
360 """Yield at most :data:`_MAX_PER_TYPE` executions (a runaway backstop)."""
361 seen = 0
362 async for execution in iterator:
363 yield execution
364 seen += 1
365 if seen >= _MAX_PER_TYPE:
366 return

What genuinely differs per advisory loop — the bespoke workflow type names and the rule/kind result key — lives in one small source-wiring table. This is the seam the runtime hasn't unified; a new advisory loop adds one row, and the rest of the dashboard is generic.

src/froot/dashboard/temporal_source.py · 366 lines
src/froot/dashboard/temporal_source.py366 lines · Python
⋯ 34 lines hidden (lines 1–34)
1"""Temporal reader: the live run ledger (scan loops + bump outcomes).
2 
3Reuses the worker's own connected client. Lists the durable scan loops (the
4signal stage's heartbeat) and the per-bump workflows; for a completed bump it
5reads the structured outcome from the workflow result (verdict + CI reading + PR
6number), and for a terminated/failed one it recovers the human reason from
7history. Everything is keyed off the deterministic workflow ids, so the
8read-model joins to GitHub by PR number and to repos by id prefix without
9parsing ambiguous slugs. Temporal keeps ~7 days, so this is the *recent* ledger;
10GitHub is the durable one.
11 
12Never raises: a failure returns an error string and whatever was gathered.
13"""
14 
15from __future__ import annotations
16 
17import re
18from datetime import datetime
19from typing import TYPE_CHECKING, Any, Final
20 
21from temporalio.client import WorkflowExecutionStatus
22 
23from froot.domain.base import Frozen
24from froot.domain.loop import Loop
25 
26if TYPE_CHECKING:
27 from collections.abc import AsyncIterator
28 
29 from temporalio.client import Client, WorkflowExecution
30 
31# A backstop so a runaway visibility store can never spin this forever.
32_MAX_PER_TYPE: Final = 500
33# The owner/name slug out of a PR url — the repo-aware join key (see fetch).
34_PR_URL: Final = re.compile(r"github\.com/([^/]+/[^/]+)/pull/\d+")
35# The bespoke per-advisory-loop runtime the source reads: which workflow types
36# carry each loop's heartbeat and per-PR reviews, and the result key naming each
37# finding's kind. A new advisory loop adds one row (its workflows stay its own);
38# everything downstream of here is generic.
39_ADVISORY_SOURCES: Final = (
40 (Loop.DETERMINISM_REVIEW, "ReviewWorkflow", "PrReviewWorkflow", "rule"),
41 (Loop.A11Y_REVIEW, "A11yReviewWorkflow", "PrA11yReviewWorkflow", "kind"),
43 
44 
45class ScanExecution(Frozen):
46 """One scan-loop execution (there may be several per id across CAN)."""
47 
48 workflow_id: str
49 status: str
50 start: datetime | None
⋯ 316 lines hidden (lines 51–366)
51 
52 
53class BumpExecution(Frozen):
54 """One bump workflow, with its outcome (if completed) or reason (if not).
55 
56 ``repo`` is the ``owner/name`` the PR was opened against, parsed from the
57 outcome's PR url; it makes the GitHub join repo-aware so two repos' PRs that
58 share a number cannot cross-attribute.
59 """
60 
61 workflow_id: str
62 status: str
63 start: datetime | None
64 close: datetime | None
65 verdict: str | None
66 ci: str | None
67 pr_number: int | None
68 repo: str | None
69 reason: str | None
70 
71 
72class AdvisoryExecution(Frozen):
73 """One advisory-loop execution (several per id across CAN).
74 
75 ``loop`` tags which advisory loop (``determinism-review`` /
76 ``a11y-review``) this heartbeat belongs to, so the read-model partitions
77 the one stream back into per-loop tabs.
78 """
79 
80 loop: str
81 workflow_id: str
82 status: str
83 start: datetime | None
84 
85 
86class PrAdvisoryExecution(Frozen):
87 """One per-PR advisory review, with its result (if completed).
88 
89 The finding count + distinct finding kinds + advisory comment come from the
90 per-PR workflow result; the repo is recovered by the read-model from the
91 deterministic workflow id (it encodes the slug). ``loop`` tags the family.
92 """
93 
94 loop: str
95 workflow_id: str
96 status: str
97 start: datetime | None
98 close: datetime | None
99 pr_number: int | None
100 head_sha: str | None
101 findings: int
102 detail: tuple[str, ...]
103 comment_url: str | None
104 
105 
106def _status(execution: WorkflowExecution) -> str:
107 """Lowercase the Temporal status enum (``continued_as_new`` etc.)."""
108 status = execution.status
109 return status.name.lower() if status is not None else "unknown"
110 
111 
112def _as_dict(result: object) -> dict[str, Any] | None:
113 """Normalise a decoded workflow result to a dict (converter-agnostic)."""
114 if isinstance(result, dict):
115 return result
116 dump = getattr(result, "model_dump", None)
117 if callable(dump):
118 dumped = dump(mode="json")
119 return dumped if isinstance(dumped, dict) else None
120 return None
121 
122 
123def _nested_kind(data: dict[str, Any], key: str) -> str | None:
124 """Read ``data[key]["kind"]`` defensively (a discriminated-union tag)."""
125 section = data.get(key)
126 if isinstance(section, dict):
127 kind = section.get("kind")
128 if isinstance(kind, str):
129 return kind
130 return None
131 
132 
133def _pr_number(data: dict[str, Any]) -> int | None:
134 """Read ``data["pr"]["number"]`` defensively."""
135 pr = data.get("pr")
136 if isinstance(pr, dict):
137 number = pr.get("number")
138 if isinstance(number, int):
139 return number
140 return None
141 
142 
143def _pr_repo(data: dict[str, Any]) -> str | None:
144 """The ``owner/name`` slug from ``data["pr"]["url"]`` defensively."""
145 pr = data.get("pr")
146 url = pr.get("url") if isinstance(pr, dict) else None
147 if isinstance(url, str):
148 match = _PR_URL.search(url)
149 if match is not None:
150 return match.group(1)
151 return None
152 
153 
154class _Outcome(Frozen):
155 """The bits of a completed bump's outcome the read-model joins on."""
156 
157 verdict: str | None
158 ci: str | None
159 pr_number: int | None
160 repo: str | None
161 
162 
163async def _outcome(client: Client, execution: WorkflowExecution) -> _Outcome:
164 """A completed bump's outcome (verdict/ci/pr/repo), or all ``None``."""
165 empty = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
166 try:
167 handle = client.get_workflow_handle(
168 execution.id, run_id=execution.run_id
169 )
170 data = _as_dict(await handle.result())
171 except Exception: # best-effort enrichment — a bad decode is never fatal
172 return empty
173 if data is None:
174 return empty
175 return _Outcome(
176 verdict=_nested_kind(data, "verdict"),
177 ci=_nested_kind(data, "ci"),
178 pr_number=_pr_number(data),
179 repo=_pr_repo(data),
180 )
181 
182 
183async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
184 """Recover a terminated/failed bump's human reason from history."""
185 try:
186 handle = client.get_workflow_handle(
187 execution.id, run_id=execution.run_id
188 )
189 found: str | None = None
190 async for event in handle.fetch_history_events():
191 terminated = event.workflow_execution_terminated_event_attributes
192 if terminated.reason:
193 found = terminated.reason
194 failed = event.workflow_execution_failed_event_attributes
195 if failed.failure.message:
196 found = failed.failure.message
197 return found
198 except Exception: # the reason is a nicety — never fail the page for it
199 return None
200 
201 
202class _AdvisoryOutcome(Frozen):
203 """The completed-review bits the read-model joins on (any advisory loop)."""
204 
205 pr_number: int | None
206 head_sha: str | None
207 findings: int
208 detail: tuple[str, ...]
209 comment_url: str | None
210 
211 
212async def _advisory_outcome(
213 client: Client, execution: WorkflowExecution, detail_key: str
214) -> _AdvisoryOutcome:
215 """A completed review's result (pr/head/findings/detail/comment).
216 
217 ``detail_key`` names the field on each finding carrying its kind — the
218 banned call (``rule``) for determinism, the gap kind (``kind``) for a11y —
219 so one reader serves every advisory loop.
220 """
221 empty = _AdvisoryOutcome(
222 pr_number=None, head_sha=None, findings=0, detail=(), comment_url=None
223 )
224 try:
225 handle = client.get_workflow_handle(
226 execution.id, run_id=execution.run_id
227 )
228 data = _as_dict(await handle.result())
229 except Exception: # best-effort enrichment — a bad decode is never fatal
230 return empty
231 if data is None:
232 return empty
233 raw = data.get("findings")
234 findings = raw if isinstance(raw, list) else []
235 detail = tuple(
236 str(f[detail_key])
237 for f in findings
238 if isinstance(f, dict) and isinstance(f.get(detail_key), str)
239 )
240 number = data.get("pr_number")
241 head = data.get("head_sha")
242 comment = data.get("comment_url")
243 return _AdvisoryOutcome(
244 pr_number=number if isinstance(number, int) else None,
245 head_sha=head if isinstance(head, str) else None,
246 findings=len(findings),
247 detail=detail,
248 comment_url=comment if isinstance(comment, str) else None,
249 )
250 
251 
252type _Ledger = tuple[
253 tuple[ScanExecution, ...],
254 tuple[BumpExecution, ...],
255 tuple[AdvisoryExecution, ...],
256 tuple[PrAdvisoryExecution, ...],
258 
259 
260async def fetch(client: Client) -> tuple[_Ledger, str | None]:
261 """Read froot's scan/bump + review executions (degrades to an error)."""
262 scans: list[ScanExecution] = []
263 bumps: list[BumpExecution] = []
264 advisory: list[AdvisoryExecution] = []
265 pr_advisory: list[PrAdvisoryExecution] = []
266 
267 def ledger() -> _Ledger:
268 return (
269 tuple(scans),
270 tuple(bumps),
271 tuple(advisory),
272 tuple(pr_advisory),
273 )
274 
275 try:
276 async for execution in _take(
277 client.list_workflows("WorkflowType = 'ScanWorkflow'")
278 ):
279 scans.append(
280 ScanExecution(
281 workflow_id=execution.id,
282 status=_status(execution),
283 start=execution.start_time,
284 )
285 )
286 async for execution in _take(
287 client.list_workflows("WorkflowType = 'BumpWorkflow'")
288 ):
289 status = _status(execution)
290 outcome = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
291 reason: str | None = None
292 if execution.status is WorkflowExecutionStatus.COMPLETED:
293 outcome = await _outcome(client, execution)
294 elif execution.status in (
295 WorkflowExecutionStatus.TERMINATED,
296 WorkflowExecutionStatus.FAILED,
297 ):
298 reason = await _reason(client, execution)
299 bumps.append(
300 BumpExecution(
301 workflow_id=execution.id,
302 status=status,
303 start=execution.start_time,
304 close=execution.close_time,
305 verdict=outcome.verdict,
306 ci=outcome.ci,
307 pr_number=outcome.pr_number,
308 repo=outcome.repo,
309 reason=reason,
310 )
311 )
312 for loop, repo_type, pr_type, detail_key in _ADVISORY_SOURCES:
313 async for execution in _take(
314 client.list_workflows(f"WorkflowType = '{repo_type}'")
315 ):
316 advisory.append(
317 AdvisoryExecution(
318 loop=loop,
319 workflow_id=execution.id,
320 status=_status(execution),
321 start=execution.start_time,
322 )
323 )
324 async for execution in _take(
325 client.list_workflows(f"WorkflowType = '{pr_type}'")
326 ):
327 review = _AdvisoryOutcome(
328 pr_number=None,
329 head_sha=None,
330 findings=0,
331 detail=(),
332 comment_url=None,
333 )
334 if execution.status is WorkflowExecutionStatus.COMPLETED:
335 review = await _advisory_outcome(
336 client, execution, detail_key
337 )
338 pr_advisory.append(
339 PrAdvisoryExecution(
340 loop=loop,
341 workflow_id=execution.id,
342 status=_status(execution),
343 start=execution.start_time,
344 close=execution.close_time,
345 pr_number=review.pr_number,
346 head_sha=review.head_sha,
347 findings=review.findings,
348 detail=review.detail,
349 comment_url=review.comment_url,
350 )
351 )
352 except Exception as exc: # degrade to an error string, never crash the page
353 return ledger(), f"{type(exc).__name__}: {exc}"
354 return ledger(), None
355 
356 
357async def _take(
358 iterator: AsyncIterator[WorkflowExecution],
359) -> AsyncIterator[WorkflowExecution]:
360 """Yield at most :data:`_MAX_PER_TYPE` executions (a runaway backstop)."""
361 seen = 0
362 async for execution in iterator:
363 yield execution
364 seen += 1
365 if seen >= _MAX_PER_TYPE:
366 return

fetch loops that table: list each loop's two workflow types, tag every execution, extract its findings' kinds with the loop's detail_key.

src/froot/dashboard/temporal_source.py · 366 lines
src/froot/dashboard/temporal_source.py366 lines · Python
⋯ 311 lines hidden (lines 1–311)
1"""Temporal reader: the live run ledger (scan loops + bump outcomes).
2 
3Reuses the worker's own connected client. Lists the durable scan loops (the
4signal stage's heartbeat) and the per-bump workflows; for a completed bump it
5reads the structured outcome from the workflow result (verdict + CI reading + PR
6number), and for a terminated/failed one it recovers the human reason from
7history. Everything is keyed off the deterministic workflow ids, so the
8read-model joins to GitHub by PR number and to repos by id prefix without
9parsing ambiguous slugs. Temporal keeps ~7 days, so this is the *recent* ledger;
10GitHub is the durable one.
11 
12Never raises: a failure returns an error string and whatever was gathered.
13"""
14 
15from __future__ import annotations
16 
17import re
18from datetime import datetime
19from typing import TYPE_CHECKING, Any, Final
20 
21from temporalio.client import WorkflowExecutionStatus
22 
23from froot.domain.base import Frozen
24from froot.domain.loop import Loop
25 
26if TYPE_CHECKING:
27 from collections.abc import AsyncIterator
28 
29 from temporalio.client import Client, WorkflowExecution
30 
31# A backstop so a runaway visibility store can never spin this forever.
32_MAX_PER_TYPE: Final = 500
33# The owner/name slug out of a PR url — the repo-aware join key (see fetch).
34_PR_URL: Final = re.compile(r"github\.com/([^/]+/[^/]+)/pull/\d+")
35# The bespoke per-advisory-loop runtime the source reads: which workflow types
36# carry each loop's heartbeat and per-PR reviews, and the result key naming each
37# finding's kind. A new advisory loop adds one row (its workflows stay its own);
38# everything downstream of here is generic.
39_ADVISORY_SOURCES: Final = (
40 (Loop.DETERMINISM_REVIEW, "ReviewWorkflow", "PrReviewWorkflow", "rule"),
41 (Loop.A11Y_REVIEW, "A11yReviewWorkflow", "PrA11yReviewWorkflow", "kind"),
43 
44 
45class ScanExecution(Frozen):
46 """One scan-loop execution (there may be several per id across CAN)."""
47 
48 workflow_id: str
49 status: str
50 start: datetime | None
51 
52 
53class BumpExecution(Frozen):
54 """One bump workflow, with its outcome (if completed) or reason (if not).
55 
56 ``repo`` is the ``owner/name`` the PR was opened against, parsed from the
57 outcome's PR url; it makes the GitHub join repo-aware so two repos' PRs that
58 share a number cannot cross-attribute.
59 """
60 
61 workflow_id: str
62 status: str
63 start: datetime | None
64 close: datetime | None
65 verdict: str | None
66 ci: str | None
67 pr_number: int | None
68 repo: str | None
69 reason: str | None
70 
71 
72class AdvisoryExecution(Frozen):
73 """One advisory-loop execution (several per id across CAN).
74 
75 ``loop`` tags which advisory loop (``determinism-review`` /
76 ``a11y-review``) this heartbeat belongs to, so the read-model partitions
77 the one stream back into per-loop tabs.
78 """
79 
80 loop: str
81 workflow_id: str
82 status: str
83 start: datetime | None
84 
85 
86class PrAdvisoryExecution(Frozen):
87 """One per-PR advisory review, with its result (if completed).
88 
89 The finding count + distinct finding kinds + advisory comment come from the
90 per-PR workflow result; the repo is recovered by the read-model from the
91 deterministic workflow id (it encodes the slug). ``loop`` tags the family.
92 """
93 
94 loop: str
95 workflow_id: str
96 status: str
97 start: datetime | None
98 close: datetime | None
99 pr_number: int | None
100 head_sha: str | None
101 findings: int
102 detail: tuple[str, ...]
103 comment_url: str | None
104 
105 
106def _status(execution: WorkflowExecution) -> str:
107 """Lowercase the Temporal status enum (``continued_as_new`` etc.)."""
108 status = execution.status
109 return status.name.lower() if status is not None else "unknown"
110 
111 
112def _as_dict(result: object) -> dict[str, Any] | None:
113 """Normalise a decoded workflow result to a dict (converter-agnostic)."""
114 if isinstance(result, dict):
115 return result
116 dump = getattr(result, "model_dump", None)
117 if callable(dump):
118 dumped = dump(mode="json")
119 return dumped if isinstance(dumped, dict) else None
120 return None
121 
122 
123def _nested_kind(data: dict[str, Any], key: str) -> str | None:
124 """Read ``data[key]["kind"]`` defensively (a discriminated-union tag)."""
125 section = data.get(key)
126 if isinstance(section, dict):
127 kind = section.get("kind")
128 if isinstance(kind, str):
129 return kind
130 return None
131 
132 
133def _pr_number(data: dict[str, Any]) -> int | None:
134 """Read ``data["pr"]["number"]`` defensively."""
135 pr = data.get("pr")
136 if isinstance(pr, dict):
137 number = pr.get("number")
138 if isinstance(number, int):
139 return number
140 return None
141 
142 
143def _pr_repo(data: dict[str, Any]) -> str | None:
144 """The ``owner/name`` slug from ``data["pr"]["url"]`` defensively."""
145 pr = data.get("pr")
146 url = pr.get("url") if isinstance(pr, dict) else None
147 if isinstance(url, str):
148 match = _PR_URL.search(url)
149 if match is not None:
150 return match.group(1)
151 return None
152 
153 
154class _Outcome(Frozen):
155 """The bits of a completed bump's outcome the read-model joins on."""
156 
157 verdict: str | None
158 ci: str | None
159 pr_number: int | None
160 repo: str | None
161 
162 
163async def _outcome(client: Client, execution: WorkflowExecution) -> _Outcome:
164 """A completed bump's outcome (verdict/ci/pr/repo), or all ``None``."""
165 empty = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
166 try:
167 handle = client.get_workflow_handle(
168 execution.id, run_id=execution.run_id
169 )
170 data = _as_dict(await handle.result())
171 except Exception: # best-effort enrichment — a bad decode is never fatal
172 return empty
173 if data is None:
174 return empty
175 return _Outcome(
176 verdict=_nested_kind(data, "verdict"),
177 ci=_nested_kind(data, "ci"),
178 pr_number=_pr_number(data),
179 repo=_pr_repo(data),
180 )
181 
182 
183async def _reason(client: Client, execution: WorkflowExecution) -> str | None:
184 """Recover a terminated/failed bump's human reason from history."""
185 try:
186 handle = client.get_workflow_handle(
187 execution.id, run_id=execution.run_id
188 )
189 found: str | None = None
190 async for event in handle.fetch_history_events():
191 terminated = event.workflow_execution_terminated_event_attributes
192 if terminated.reason:
193 found = terminated.reason
194 failed = event.workflow_execution_failed_event_attributes
195 if failed.failure.message:
196 found = failed.failure.message
197 return found
198 except Exception: # the reason is a nicety — never fail the page for it
199 return None
200 
201 
202class _AdvisoryOutcome(Frozen):
203 """The completed-review bits the read-model joins on (any advisory loop)."""
204 
205 pr_number: int | None
206 head_sha: str | None
207 findings: int
208 detail: tuple[str, ...]
209 comment_url: str | None
210 
211 
212async def _advisory_outcome(
213 client: Client, execution: WorkflowExecution, detail_key: str
214) -> _AdvisoryOutcome:
215 """A completed review's result (pr/head/findings/detail/comment).
216 
217 ``detail_key`` names the field on each finding carrying its kind — the
218 banned call (``rule``) for determinism, the gap kind (``kind``) for a11y —
219 so one reader serves every advisory loop.
220 """
221 empty = _AdvisoryOutcome(
222 pr_number=None, head_sha=None, findings=0, detail=(), comment_url=None
223 )
224 try:
225 handle = client.get_workflow_handle(
226 execution.id, run_id=execution.run_id
227 )
228 data = _as_dict(await handle.result())
229 except Exception: # best-effort enrichment — a bad decode is never fatal
230 return empty
231 if data is None:
232 return empty
233 raw = data.get("findings")
234 findings = raw if isinstance(raw, list) else []
235 detail = tuple(
236 str(f[detail_key])
237 for f in findings
238 if isinstance(f, dict) and isinstance(f.get(detail_key), str)
239 )
240 number = data.get("pr_number")
241 head = data.get("head_sha")
242 comment = data.get("comment_url")
243 return _AdvisoryOutcome(
244 pr_number=number if isinstance(number, int) else None,
245 head_sha=head if isinstance(head, str) else None,
246 findings=len(findings),
247 detail=detail,
248 comment_url=comment if isinstance(comment, str) else None,
249 )
250 
251 
252type _Ledger = tuple[
253 tuple[ScanExecution, ...],
254 tuple[BumpExecution, ...],
255 tuple[AdvisoryExecution, ...],
256 tuple[PrAdvisoryExecution, ...],
258 
259 
260async def fetch(client: Client) -> tuple[_Ledger, str | None]:
261 """Read froot's scan/bump + review executions (degrades to an error)."""
262 scans: list[ScanExecution] = []
263 bumps: list[BumpExecution] = []
264 advisory: list[AdvisoryExecution] = []
265 pr_advisory: list[PrAdvisoryExecution] = []
266 
267 def ledger() -> _Ledger:
268 return (
269 tuple(scans),
270 tuple(bumps),
271 tuple(advisory),
272 tuple(pr_advisory),
273 )
274 
275 try:
276 async for execution in _take(
277 client.list_workflows("WorkflowType = 'ScanWorkflow'")
278 ):
279 scans.append(
280 ScanExecution(
281 workflow_id=execution.id,
282 status=_status(execution),
283 start=execution.start_time,
284 )
285 )
286 async for execution in _take(
287 client.list_workflows("WorkflowType = 'BumpWorkflow'")
288 ):
289 status = _status(execution)
290 outcome = _Outcome(verdict=None, ci=None, pr_number=None, repo=None)
291 reason: str | None = None
292 if execution.status is WorkflowExecutionStatus.COMPLETED:
293 outcome = await _outcome(client, execution)
294 elif execution.status in (
295 WorkflowExecutionStatus.TERMINATED,
296 WorkflowExecutionStatus.FAILED,
297 ):
298 reason = await _reason(client, execution)
299 bumps.append(
300 BumpExecution(
301 workflow_id=execution.id,
302 status=status,
303 start=execution.start_time,
304 close=execution.close_time,
305 verdict=outcome.verdict,
306 ci=outcome.ci,
307 pr_number=outcome.pr_number,
308 repo=outcome.repo,
309 reason=reason,
310 )
311 )
312 for loop, repo_type, pr_type, detail_key in _ADVISORY_SOURCES:
313 async for execution in _take(
314 client.list_workflows(f"WorkflowType = '{repo_type}'")
315 ):
316 advisory.append(
317 AdvisoryExecution(
318 loop=loop,
319 workflow_id=execution.id,
320 status=_status(execution),
321 start=execution.start_time,
322 )
323 )
324 async for execution in _take(
325 client.list_workflows(f"WorkflowType = '{pr_type}'")
326 ):
327 review = _AdvisoryOutcome(
328 pr_number=None,
329 head_sha=None,
330 findings=0,
331 detail=(),
332 comment_url=None,
333 )
334 if execution.status is WorkflowExecutionStatus.COMPLETED:
335 review = await _advisory_outcome(
336 client, execution, detail_key
337 )
338 pr_advisory.append(
339 PrAdvisoryExecution(
340 loop=loop,
341 workflow_id=execution.id,
342 status=_status(execution),
343 start=execution.start_time,
344 close=execution.close_time,
345 pr_number=review.pr_number,
346 head_sha=review.head_sha,
347 findings=review.findings,
348 detail=review.detail,
349 comment_url=review.comment_url,
350 )
351 )
⋯ 15 lines hidden (lines 352–366)
352 except Exception as exc: # degrade to an error string, never crash the page
353 return ledger(), f"{type(exc).__name__}: {exc}"
354 return ledger(), None
355 
356 
357async def _take(
358 iterator: AsyncIterator[WorkflowExecution],
359) -> AsyncIterator[WorkflowExecution]:
360 """Yield at most :data:`_MAX_PER_TYPE` executions (a runaway backstop)."""
361 seen = 0
362 async for execution in iterator:
363 yield execution
364 seen += 1
365 if seen >= _MAX_PER_TYPE:
366 return

The read-model derives the views from the registry

The per-PR id join was the one place the two families' namespaces differed. A small dispatch maps each loop to its per-repo id namer; the per-PR prefix is then a uniform transform of that id — widen froot- to froot-pr-, append the dash before the PR/SHA tail.

src/froot/dashboard/read_model.py · 861 lines
src/froot/dashboard/read_model.py861 lines · Python
⋯ 581 lines hidden (lines 1–581)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 AdvisoryLoop,
18 AdvisoryRecord,
19 AdvisoryRow,
20 AdvisoryView,
21 BumpRow,
22 ClassGate,
23 DashboardModel,
24 Failure,
25 Judgment,
26 LoopView,
27 Probes,
28 Reliability,
29 RunTelemetry,
30 ScanLoop,
31 SourceHealth,
32 TrackRecord,
33 Verification,
35from froot.domain.loop import Loop
36from froot.domain.repo import RepoRef, TargetRepo
37from froot.loops import registry
38from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
39from froot.policy.canary import is_canary, score_probe
40from froot.policy.naming import (
41 a11y_review_workflow_id,
42 review_workflow_id,
43 scan_workflow_id,
45from froot.result import Ok
46 
47if TYPE_CHECKING:
48 from datetime import datetime
49 
50 from froot.dashboard.github_source import GithubPr
51 from froot.dashboard.temporal_source import (
52 AdvisoryExecution,
53 BumpExecution,
54 PrAdvisoryExecution,
55 ScanExecution,
56 )
57 
58# The conservative fallback policy: empty allowlist, so the shadow gate holds
59# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
60_DEFAULT_POLICY = AutonomyPolicy()
61 
62_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
63# Every way a bump can end without closing cleanly — all belong in the honest
64# Failures panel, not silently dropped.
65_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
66 
67 
68def _median(values: list[float]) -> float | None:
69 """The median of ``values``, or ``None`` if empty (pure)."""
70 if not values:
71 return None
72 ordered = sorted(values)
73 mid = len(ordered) // 2
74 if len(ordered) % 2 == 1:
75 return ordered[mid]
76 return (ordered[mid - 1] + ordered[mid]) / 2
77 
78 
79def _scan_id(repo: str, loop: Loop) -> str | None:
80 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
81 match RepoRef.parse(repo):
82 case Ok(ref):
83 return scan_workflow_id(TargetRepo(repo=ref), loop)
84 case _:
85 return None
86 
87 
88def _scan_loops(
89 repos: tuple[str, ...],
90 loops: tuple[Loop, ...],
91 scans: tuple[ScanExecution, ...],
92) -> tuple[ScanLoop, ...]:
93 """One liveness row per configured (repo, loop) (latest execution wins)."""
94 by_id: dict[str, ScanExecution] = {}
95 for scan in scans:
96 current = by_id.get(scan.workflow_id)
97 if current is None or _newer(scan.start, current.start):
98 by_id[scan.workflow_id] = scan
99 rows: list[ScanLoop] = []
100 for loop in loops:
101 for repo in repos:
102 scan_id = _scan_id(repo, loop)
103 execution = by_id.get(scan_id) if scan_id is not None else None
104 if execution is None:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status="none",
110 live=False,
111 last_tick=None,
112 )
113 )
114 else:
115 rows.append(
116 ScanLoop(
117 repo=repo,
118 loop=loop.value,
119 status=execution.status,
120 live=execution.status in _LIVE_STATUSES,
121 last_tick=execution.start,
122 )
123 )
124 return tuple(rows)
125 
126 
127def _newer(a: datetime | None, b: datetime | None) -> bool:
128 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
129 if a is None:
130 return False
131 if b is None:
132 return True
133 return a > b
134 
135 
136def _bump_rows(
137 now: datetime,
138 prs: tuple[GithubPr, ...],
139 bumps: tuple[BumpExecution, ...],
140 outcomes: dict[tuple[str, int], str],
141) -> tuple[BumpRow, ...]:
142 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number.
143 
144 ``outcomes`` carries the post-merge signal (``held`` / ``broke`` /
145 ``reverted`` / ``unknown``) per recently-merged ``(repo, number)``; it is
146 absent for older or non-merged PRs, which leaves ``post_merge`` ``None``.
147 """
148 # Keyed on (repo, PR number), not the bare number: Temporal lists every
149 # repo's bumps in the namespace, so two repos that each have a PR #N would
150 # otherwise cross-attribute one's verdict/CI onto the other's row.
151 by_pr: dict[tuple[str, int], BumpExecution] = {
152 (bump.repo, bump.pr_number): bump
153 for bump in bumps
154 if bump.repo is not None and bump.pr_number is not None
155 }
156 rows: list[BumpRow] = []
157 for pr in prs:
158 execution = by_pr.get((pr.repo, pr.number))
159 verdict = (execution.verdict if execution else None) or pr.verdict
160 ci = execution.ci if execution else None
161 ttm = _minutes_between(pr.opened_at, pr.merged_at)
162 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
163 # A removal carries no version: show "unused" in the target column and
164 # no from-version, so a dead-code row reads "left-pad -> unused" rather
165 # than the bump-shaped "left-pad -> ?".
166 is_removal = pr.loop == Loop.DEAD_CODE.value
167 rows.append(
168 BumpRow(
169 repo=pr.repo,
170 loop=pr.loop,
171 package=pr.package or "?",
172 from_version=None if is_removal else pr.from_version,
173 to_version="unused" if is_removal else (pr.to_version or "?"),
174 state=pr.state,
175 verdict=verdict,
176 ci=ci,
177 pr_number=pr.number,
178 pr_url=pr.url,
179 opened_at=pr.opened_at,
180 merged_at=pr.merged_at,
181 ttm_minutes=ttm,
182 age_hours=age,
183 post_merge=outcomes.get((pr.repo, pr.number)),
184 env=pr.env,
185 )
186 )
187 rows.sort(key=_opened_sort_key, reverse=True)
188 return tuple(rows)
189 
190 
191def _opened_sort_key(row: BumpRow) -> float:
192 """Sort key putting the most recently opened PR first (unknown last)."""
193 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
194 
195 
196def _minutes_between(
197 start: datetime | None, end: datetime | None
198) -> float | None:
199 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
200 if start is None or end is None:
201 return None
202 return round((end - start).total_seconds() / 60, 1)
203 
204 
205def _hours_between(
206 start: datetime | None, end: datetime | None
207) -> float | None:
208 """Hours from ``start`` to ``end``, or ``None``."""
209 if start is None or end is None:
210 return None
211 return round((end - start).total_seconds() / 3600, 1)
212 
213 
214def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
215 """Counts, merge rate, and median time-to-merge from the bump rows."""
216 merged = [r for r in rows if r.state == "merged"]
217 closed = sum(1 for r in rows if r.state == "closed")
218 open_now = sum(1 for r in rows if r.state == "open")
219 decided = len(merged) + closed
220 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
221 return TrackRecord(
222 opened=len(rows),
223 merged=len(merged),
224 closed_unmerged=closed,
225 open_now=open_now,
226 merge_rate=(len(merged) / decided) if decided else None,
227 median_ttm_minutes=_median(ttms),
228 )
229 
230 
231def _verification(rows: tuple[BumpRow, ...]) -> Verification:
232 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
233 passed = sum(1 for r in rows if r.ci == "passed")
234 failed = sum(1 for r in rows if r.ci == "failed")
235 absent = sum(1 for r in rows if r.ci == "absent")
236 timed_out = sum(1 for r in rows if r.ci == "timed_out")
237 unknown = sum(1 for r in rows if r.ci is None)
238 return Verification(
239 passed=passed,
240 failed=failed,
241 absent=absent,
242 timed_out=timed_out,
243 unknown=unknown,
244 oracle_existed=passed + failed,
245 with_reading=passed + failed + absent + timed_out,
246 )
247 
248 
249def _reliability(rows: tuple[BumpRow, ...], window_days: int) -> Reliability:
250 """The post-merge outcome breakdown — did the merges hold? (coarse).
251 
252 Reads only the ``post_merge`` tag the outcome reader set: ``unknown`` means
253 an in-window merge we could not classify (no branch oracle / aged out), kept
254 distinct from a held one; ``None`` (older / non-merged) is not counted here.
255 The defect rate is a floor — manual and bundled reverts are invisible.
256 """
257 held = sum(1 for r in rows if r.post_merge == "held")
258 broke = sum(1 for r in rows if r.post_merge == "broke")
259 reverted = sum(1 for r in rows if r.post_merge == "reverted")
260 unverified = sum(1 for r in rows if r.post_merge == "unknown")
261 determined = held + broke + reverted
262 return Reliability(
263 held=held,
264 broke=broke,
265 reverted=reverted,
266 unverified=unverified,
267 determined=determined,
268 defect_rate=((broke + reverted) / determined) if determined else None,
269 window_days=window_days,
270 )
271 
272 
273def _probes(canary_rows: tuple[BumpRow, ...]) -> Probes:
274 """Tally the adversarial canary probes — caught / escaped / pending.
275 
276 These rows are the synthetic bad bumps; they are scored on the strict bar
277 (a canary must never merge) and kept out of every genuine bearing.
278 """
279 scored = [score_probe(r.state) for r in canary_rows]
280 return Probes(
281 caught=sum(1 for s in scored if s == "caught"),
282 escaped=sum(1 for s in scored if s == "escaped"),
283 pending=sum(1 for s in scored if s == "pending"),
284 total=len(scored),
285 )
286 
287 
288def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
289 """The verdict mix plus the two calibration cells worth flagging."""
290 clean = sum(1 for r in rows if r.verdict == "clean")
291 risky = sum(1 for r in rows if r.verdict == "risky")
292 unknown = sum(1 for r in rows if r.verdict == "unknown")
293 none = sum(1 for r in rows if r.verdict is None)
294 clean_but_failed = sum(
295 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
296 )
297 flagged_but_passed = sum(
298 1
299 for r in rows
300 if r.verdict in ("risky", "unknown") and r.ci == "passed"
301 )
302 return Judgment(
303 clean=clean,
304 risky=risky,
305 unknown=unknown,
306 none=none,
307 clean_but_failed=clean_but_failed,
308 flagged_but_passed=flagged_but_passed,
309 )
310 
311 
312def _decided_at(row: BumpRow) -> datetime | None:
313 """When a decided PR was decided — merge time, else open time as a proxy.
314 
315 A merged PR has an exact merge instant; a closed-unmerged one does not (the
316 issues list froot reads carries no close timestamp), so it is windowed by
317 when it opened. The proxy only nudges a closed PR's window membership by its
318 own lifetime — close enough for a recency window measured in months.
319 """
320 return row.merged_at or row.opened_at
321 
322 
323def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
324 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
325 if when is None:
326 return False
327 return (now - when).total_seconds() <= window_days * 86400.0
328 
329 
330def _class_gates(
331 now: datetime,
332 rows: tuple[BumpRow, ...],
333 repos: tuple[str, ...],
334 loops: tuple[Loop, ...],
335 policy: AutonomyPolicy,
336 environment: str,
337) -> tuple[ClassGate, ...]:
338 """The earned-autonomy standing of each (repo, loop) class.
339 
340 Counts only PRs *decided within the window* — trust is recent, not lifetime
341 (§2.11) — *and* earned under the current ``environment`` (§3.7's conditional
342 property): a PR opened under a different judge model no longer counts, so a
343 model swap resets the class. ``prior_env_decided`` keeps that reset legible.
344 The budget figures (approvals / reclaim per week) translate the record into
345 the steward-time MHE meters (§3.6): what the class costs now, and what
346 moving its gate would hand back.
347 """
348 weeks = max(policy.window_days / 7.0, 1.0)
349 gates: list[ClassGate] = []
350 for loop in loops:
351 for repo in repos:
352 in_window = [
353 r
354 for r in rows
355 if r.repo == repo
356 and r.loop == loop.value
357 and r.state in ("merged", "closed")
358 and _within(_decided_at(r), now, policy.window_days)
359 ]
360 # An empty ``environment`` means "don't filter" (none configured);
361 # otherwise only PRs stamped with the current one count.
362 decided_rows = [
363 r for r in in_window if not environment or r.env == environment
364 ]
365 prior_env_decided = len(in_window) - len(decided_rows)
366 merged_rows = [r for r in decided_rows if r.state == "merged"]
367 decided = len(decided_rows)
368 merged = len(merged_rows)
369 # The post-merge defect bearing, per class (§3.8): confirmed-held
370 # outcomes and how many of those went bad. Independent of the rate.
371 determined = sum(
372 1
373 for r in merged_rows
374 if r.post_merge in ("held", "broke", "reverted")
375 )
376 defects = sum(
377 1 for r in merged_rows if r.post_merge in ("broke", "reverted")
378 )
379 earned, blocker = class_earned(
380 decided=decided,
381 merged=merged,
382 determined=determined,
383 defects=defects,
384 policy=policy,
385 )
386 # Reclaim is the budget a gate *move* hands back — so it is zero
387 # until the class has actually earned the move. Counting the
388 # clean-and-green merges of an un-earned class would imply savings
389 # the gate would refuse (every PR stays held at "class not earned").
390 reclaimable = (
391 sum(
392 1
393 for r in merged_rows
394 if r.verdict == "clean" and r.ci == "passed"
395 )
396 if earned
397 else 0
398 )
399 gates.append(
400 ClassGate(
401 repo=repo,
402 loop=loop.value,
403 decided=decided,
404 merged=merged,
405 merge_rate=(merged / decided) if decided else None,
406 determined=determined,
407 defects=defects,
408 defect_rate=(
409 (defects / determined) if determined else None
410 ),
411 prior_env_decided=prior_env_decided,
412 earned=earned,
413 blocker=blocker,
414 approvals_per_week=round(merged / weeks, 2),
415 reclaim_per_week=round(reclaimable / weeks, 2),
416 window_days=policy.window_days,
417 )
418 )
419 return tuple(gates)
420 
421 
422def earned_now(
423 now: datetime,
424 prs: tuple[GithubPr, ...],
425 outcomes: dict[tuple[str, int], str],
426 repo: str,
427 loop: Loop,
428 policy: AutonomyPolicy,
429 environment: str,
430) -> bool:
431 """Whether one (repo, loop) class has earned its gate, from live data.
432 
433 The exact computation the dashboard's class-gate panel shows, exposed so the
434 acting gate (the loop) reuses it rather than duplicating the windowing,
435 environment filter, and triangulation. Pure over already-fetched data.
436 """
437 rows = _bump_rows(now, prs, (), outcomes)
438 gates = _class_gates(now, rows, (repo,), (loop,), policy, environment)
439 return any(g.earned for g in gates)
440 
441 
442def _gate(
443 rows: tuple[BumpRow, ...],
444 gates: tuple[ClassGate, ...],
445 policy: AutonomyPolicy,
446) -> tuple[BumpRow, ...]:
447 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
448 
449 The verdict is the gate's: would this PR auto-merge under its class's grant
450 — the loop's real decision where the repo is allowlisted, advisory (the
451 shadow gate) where it is not. The reason is the grant met, or the first
452 blocker to fix.
453 """
454 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
455 open_rows = [r for r in rows if r.state == "open"]
456 open_rows.sort(
457 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
458 reverse=True,
459 )
460 annotated: list[BumpRow] = []
461 for row in open_rows:
462 earned, blocker = earned_by_class.get(
463 (row.repo, row.loop), (False, "no record for this class")
464 )
465 verdict = pr_autonomy(
466 repo=row.repo,
467 verdict=row.verdict,
468 ci=row.ci,
469 earned=earned,
470 blocker=blocker,
471 policy=policy,
472 )
473 annotated.append(
474 row.model_copy(
475 update={
476 "would_auto_merge": verdict.would_merge,
477 "held_reason": (
478 None if verdict.would_merge else verdict.reason
479 ),
480 }
481 )
482 )
483 return tuple(annotated)
484 
485 
486def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
487 """Bump loops that did not close, newest first."""
488 failures = [
489 Failure(
490 workflow_id=bump.workflow_id,
491 kind=bump.status,
492 reason=bump.reason,
493 when=bump.close,
494 )
495 for bump in bumps
496 if bump.status in _FAILURE_STATUSES
497 ]
498 failures.sort(
499 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
500 reverse=True,
501 )
502 return tuple(failures)
503 
504 
505def _loop_title(loop: Loop) -> str:
506 """The human tab title for a loop key (``dependency-patch`` -> ...)."""
507 return loop.value.capitalize()
508 
509 
510def _failure_loop(workflow_id: str, loops: tuple[Loop, ...]) -> Loop | None:
511 """Which loop a failed bump belongs to, from its workflow id.
512 
513 Non-default loops carry their name as an id segment
514 (``froot-bump-security-patch-…``); dependency-patch is segmentless
515 (``froot-bump-…``), so it is the fallback once the specific loops miss.
516 """
517 for loop in loops:
518 if loop is not Loop.DEPENDENCY_PATCH and workflow_id.startswith(
519 f"froot-bump-{loop.value}-"
520 ):
521 return loop
522 if workflow_id.startswith("froot-bump-"):
523 return next(
524 (loop for loop in loops if loop is Loop.DEPENDENCY_PATCH), None
525 )
526 return None
527 
528 
529def _bump_loops(
530 now: datetime,
531 rows: tuple[BumpRow, ...],
532 canary_rows: tuple[BumpRow, ...],
533 failures: tuple[Failure, ...],
534 scans: tuple[ScanExecution, ...],
535 repos: tuple[str, ...],
536 loops: tuple[Loop, ...],
537 policy: AutonomyPolicy,
538 environment: str,
539 scan_interval_seconds: int,
540 reliability_window_days: int,
541) -> tuple[LoopView, ...]:
542 """One self-contained :class:`LoopView` per loop — the per-loop tabs.
543 
544 Partitions the already-computed bump rows by loop and runs the very same
545 aggregates the combined view uses, so each loop's tab is the whole dashboard
546 scoped to one trust class (§3.9). Failures are attributed by workflow id.
547 """
548 views: list[LoopView] = []
549 for loop in loops:
550 loop_rows = tuple(r for r in rows if r.loop == loop.value)
551 loop_canary = tuple(r for r in canary_rows if r.loop == loop.value)
552 loop_gates = _class_gates(
553 now, loop_rows, repos, (loop,), policy, environment
554 )
555 loop_failures = tuple(
556 f for f in failures if _failure_loop(f.workflow_id, loops) is loop
557 )
558 views.append(
559 LoopView(
560 loop=loop.value,
561 title=_loop_title(loop),
562 icon=registry.get(loop).dashboard_icon,
563 scan_loops=_scan_loops(repos, (loop,), scans),
564 scan_interval_seconds=scan_interval_seconds,
565 track_record=_track_record(loop_rows),
566 class_gates=loop_gates,
567 verification=_verification(loop_rows),
568 reliability=_reliability(loop_rows, reliability_window_days),
569 probes=_probes(loop_canary),
570 judgment=_judgment(loop_rows),
571 gate=_gate(loop_rows, loop_gates, policy),
572 bumps=loop_rows,
573 failures=loop_failures,
574 )
575 )
576 return tuple(views)
577 
578 
579# Which per-repo-loop-id namer each advisory loop uses. The naming is bespoke
580# (the loops' workflows are still their own), so the join keeps a tiny dispatch;
581# the per-PR prefix is then a uniform transform of the loop id (below).
582_ADVISORY_LOOP_ID = {
583 Loop.DETERMINISM_REVIEW: review_workflow_id,
584 Loop.A11Y_REVIEW: a11y_review_workflow_id,
586 
587 
588def _advisory_loop_id(loop: Loop, repo: str) -> str | None:
589 """The deterministic per-repo loop id for ``repo``, if valid and known."""
590 namer = _ADVISORY_LOOP_ID.get(loop)
591 if namer is None:
592 return None
593 match RepoRef.parse(repo):
594 case Ok(ref):
595 return namer(TargetRepo(repo=ref))
596 case _:
597 return None
598 
599 
600def _pr_advisory_prefix(loop: Loop, repo: str) -> str | None:
601 """The id prefix every per-PR review of ``(loop, repo)`` shares (join key).
602 
603 Each per-PR id is the loop id with ``froot-`` widened to ``froot-pr-``, then
604 the ``-<pr>-<sha>`` tail; the shared prefix is that, sans tail.
605 """
606 loop_id = _advisory_loop_id(loop, repo)
607 if loop_id is None:
608 return None
609 return "froot-pr-" + loop_id.removeprefix("froot-") + "-"
610 
611 
612def _attribute_advisory_repo(
613 loop: Loop, workflow_id: str, repos: tuple[str, ...]
614) -> str | None:
615 """The configured repo a per-PR-review id belongs to (longest prefix)."""
616 best: str | None = None
617 best_len = -1
618 for repo in repos:
619 prefix = _pr_advisory_prefix(loop, repo)
620 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
621 best, best_len = repo, len(prefix)
622 return best
⋯ 239 lines hidden (lines 623–861)
623 
624 
625def _advisory_loops(
626 loop: Loop, repos: tuple[str, ...], execs: tuple[AdvisoryExecution, ...]
627) -> tuple[AdvisoryLoop, ...]:
628 """One liveness row per repo that actually runs this advisory loop.
629 
630 Executions are scoped to the Temporal repos, so a configured repo with no
631 such loop is omitted rather than shown as a dead one.
632 """
633 by_id: dict[str, AdvisoryExecution] = {}
634 for candidate in execs:
635 if candidate.loop != loop:
636 continue
637 current = by_id.get(candidate.workflow_id)
638 if current is None or _newer(candidate.start, current.start):
639 by_id[candidate.workflow_id] = candidate
640 loops: list[AdvisoryLoop] = []
641 for repo in repos:
642 loop_id = _advisory_loop_id(loop, repo)
643 execution = by_id.get(loop_id) if loop_id is not None else None
644 if execution is None:
645 continue
646 loops.append(
647 AdvisoryLoop(
648 repo=repo,
649 status=execution.status,
650 live=execution.status in _LIVE_STATUSES,
651 last_tick=execution.start,
652 )
653 )
654 return tuple(loops)
655 
656 
657def _advisory_rows(
658 loop: Loop,
659 pr_execs: tuple[PrAdvisoryExecution, ...],
660 repos: tuple[str, ...],
661) -> tuple[AdvisoryRow, ...]:
662 """Project each per-PR review of this loop into a row, newest first."""
663 rows: list[AdvisoryRow] = []
664 for execution in pr_execs:
665 if execution.loop != loop:
666 continue
667 repo = _attribute_advisory_repo(loop, execution.workflow_id, repos)
668 pr_url = (
669 f"https://github.com/{repo}/pull/{execution.pr_number}"
670 if repo is not None and execution.pr_number is not None
671 else None
672 )
673 rows.append(
674 AdvisoryRow(
675 repo=repo or "?",
676 pr_number=execution.pr_number,
677 pr_url=pr_url,
678 head_sha=execution.head_sha,
679 findings=execution.findings,
680 detail=execution.detail,
681 comment_url=execution.comment_url,
682 status=execution.status,
683 reviewed_at=execution.close or execution.start,
684 )
685 )
686 rows.sort(
687 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
688 reverse=True,
689 )
690 return tuple(rows)
691 
692 
693def _advisory_record(
694 loops: tuple[AdvisoryLoop, ...], rows: tuple[AdvisoryRow, ...]
695) -> AdvisoryRecord:
696 """Counts over this loop's completed reviews."""
697 completed = [r for r in rows if r.status == "completed"]
698 flagged = sum(1 for r in completed if r.findings > 0)
699 findings = sum(r.findings for r in completed)
700 return AdvisoryRecord(
701 reviewed=len(completed),
702 flagged=flagged,
703 clean=len(completed) - flagged,
704 findings=findings,
705 repos_covered=len(loops),
706 )
707 
708 
709def _advisory_views(
710 repos: tuple[str, ...],
711 execs: tuple[AdvisoryExecution, ...],
712 pr_execs: tuple[PrAdvisoryExecution, ...],
713 intervals: dict[Loop, int],
714) -> tuple[AdvisoryView, ...]:
715 """One tab per registered advisory loop, presentation from the registry.
716 
717 The emit-signal specs drive both which tabs exist and how each is labelled
718 (icon, title), so registering an advisory loop is all it takes to surface
719 its tab — nothing here changes.
720 """
721 views: list[AdvisoryView] = []
722 for spec in registry.all_specs():
723 tail = spec.tail
724 if not isinstance(tail, registry.AdvisoryTail):
725 continue
726 loop = spec.loop
727 loops = _advisory_loops(loop, repos, execs)
728 rows = _advisory_rows(loop, pr_execs, repos)
729 views.append(
730 AdvisoryView(
731 loop=loop,
732 icon=spec.dashboard_icon,
733 title=tail.panel_title,
734 interval_seconds=intervals.get(loop, 0),
735 loops=loops,
736 record=_advisory_record(loops, rows),
737 rows=rows,
738 )
739 )
740 return tuple(views)
741 
742 
743def _sources(
744 github_error: str | None,
745 github_count: int,
746 temporal_error: str | None,
747 temporal_count: int,
748 telemetry: RunTelemetry,
749 clickhouse_error: str | None,
750) -> tuple[SourceHealth, ...]:
751 """Per-source health for the header strip."""
752 clickhouse_ok = clickhouse_error is None
753 if clickhouse_error == "off":
754 clickhouse_detail = "off"
755 elif clickhouse_error is not None:
756 clickhouse_detail = clickhouse_error
757 else:
758 spans = telemetry.total_spans
759 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
760 return (
761 SourceHealth(
762 name="github",
763 ok=github_error is None,
764 detail=github_error or f"{github_count} PRs",
765 ),
766 SourceHealth(
767 name="temporal",
768 ok=temporal_error is None,
769 detail=temporal_error or f"{temporal_count} workflows",
770 ),
771 SourceHealth(
772 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
773 ),
774 )
775 
776 
777def assemble(
778 *,
779 now: datetime,
780 repos: tuple[str, ...],
781 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
782 policy: AutonomyPolicy = _DEFAULT_POLICY,
783 scan_interval_seconds: int,
784 advisory_intervals: dict[Loop, int] | None = None,
785 github: tuple[tuple[GithubPr, ...], str | None],
786 temporal: tuple[
787 tuple[
788 tuple[ScanExecution, ...],
789 tuple[BumpExecution, ...],
790 tuple[AdvisoryExecution, ...],
791 tuple[PrAdvisoryExecution, ...],
792 ],
793 str | None,
794 ],
795 telemetry: tuple[RunTelemetry, str | None],
796 outcomes: dict[tuple[str, int], str] | None = None,
797 reliability_window_days: int = 90,
798 environment: str = "",
799) -> DashboardModel:
800 """Build the whole view from the readers' ``(data, error)`` outputs.
801 
802 ``outcomes`` is the post-merge signal per ``(repo, number)`` from the
803 outcome reader (best-effort); absent for a merge older than the window.
804 """
805 prs, github_error = github
806 (
807 (scans, bumps, advisory, pr_advisory),
808 temporal_error,
809 ) = temporal
810 run_telemetry, clickhouse_error = telemetry
811 
812 all_rows = _bump_rows(now, prs, bumps, outcomes or {})
813 # Canary probes are synthetic bad bumps — keep them out of every genuine
814 # bearing (track record, defect rate, gates) so a planted failure can never
815 # pollute the real reputation; they get their own tally.
816 canary_rows = tuple(r for r in all_rows if is_canary(r.to_version))
817 rows = tuple(r for r in all_rows if not is_canary(r.to_version))
818 class_gates = _class_gates(now, rows, repos, loops, policy, environment)
819 all_failures = _failures(bumps)
820 bump_loops = _bump_loops(
821 now,
822 rows,
823 canary_rows,
824 all_failures,
825 scans,
826 repos,
827 loops,
828 policy,
829 environment,
830 scan_interval_seconds,
831 reliability_window_days,
832 )
833 advisory_views = _advisory_views(
834 repos, advisory, pr_advisory, advisory_intervals or {}
835 )
836 return DashboardModel(
837 generated_at=now,
838 repos_configured=repos,
839 scan_interval_seconds=scan_interval_seconds,
840 sources=_sources(
841 github_error,
842 len(prs),
843 temporal_error,
844 len(scans) + len(bumps) + len(advisory) + len(pr_advisory),
845 run_telemetry,
846 clickhouse_error,
847 ),
848 scan_loops=_scan_loops(repos, loops, scans),
849 track_record=_track_record(rows),
850 class_gates=class_gates,
851 verification=_verification(rows),
852 reliability=_reliability(rows, reliability_window_days),
853 probes=_probes(canary_rows),
854 judgment=_judgment(rows),
855 gate=_gate(rows, class_gates, policy),
856 bumps=rows,
857 failures=all_failures,
858 advisory=advisory_views,
859 telemetry=run_telemetry,
860 bump_loops=bump_loops,
861 )

The builder loops the registry's specs, keeps the ones whose tail is an AdvisoryTail (the emit-signal loops; the acting CommitTail loops are skipped), and builds each view with its icon and title read straight from the spec. Register an advisory spec and its tab appears — this is the consumer that makes cut #2a's registration real.

src/froot/dashboard/read_model.py · 861 lines
src/froot/dashboard/read_model.py861 lines · Python
⋯ 708 lines hidden (lines 1–708)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 AdvisoryLoop,
18 AdvisoryRecord,
19 AdvisoryRow,
20 AdvisoryView,
21 BumpRow,
22 ClassGate,
23 DashboardModel,
24 Failure,
25 Judgment,
26 LoopView,
27 Probes,
28 Reliability,
29 RunTelemetry,
30 ScanLoop,
31 SourceHealth,
32 TrackRecord,
33 Verification,
35from froot.domain.loop import Loop
36from froot.domain.repo import RepoRef, TargetRepo
37from froot.loops import registry
38from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
39from froot.policy.canary import is_canary, score_probe
40from froot.policy.naming import (
41 a11y_review_workflow_id,
42 review_workflow_id,
43 scan_workflow_id,
45from froot.result import Ok
46 
47if TYPE_CHECKING:
48 from datetime import datetime
49 
50 from froot.dashboard.github_source import GithubPr
51 from froot.dashboard.temporal_source import (
52 AdvisoryExecution,
53 BumpExecution,
54 PrAdvisoryExecution,
55 ScanExecution,
56 )
57 
58# The conservative fallback policy: empty allowlist, so the shadow gate holds
59# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
60_DEFAULT_POLICY = AutonomyPolicy()
61 
62_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
63# Every way a bump can end without closing cleanly — all belong in the honest
64# Failures panel, not silently dropped.
65_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
66 
67 
68def _median(values: list[float]) -> float | None:
69 """The median of ``values``, or ``None`` if empty (pure)."""
70 if not values:
71 return None
72 ordered = sorted(values)
73 mid = len(ordered) // 2
74 if len(ordered) % 2 == 1:
75 return ordered[mid]
76 return (ordered[mid - 1] + ordered[mid]) / 2
77 
78 
79def _scan_id(repo: str, loop: Loop) -> str | None:
80 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
81 match RepoRef.parse(repo):
82 case Ok(ref):
83 return scan_workflow_id(TargetRepo(repo=ref), loop)
84 case _:
85 return None
86 
87 
88def _scan_loops(
89 repos: tuple[str, ...],
90 loops: tuple[Loop, ...],
91 scans: tuple[ScanExecution, ...],
92) -> tuple[ScanLoop, ...]:
93 """One liveness row per configured (repo, loop) (latest execution wins)."""
94 by_id: dict[str, ScanExecution] = {}
95 for scan in scans:
96 current = by_id.get(scan.workflow_id)
97 if current is None or _newer(scan.start, current.start):
98 by_id[scan.workflow_id] = scan
99 rows: list[ScanLoop] = []
100 for loop in loops:
101 for repo in repos:
102 scan_id = _scan_id(repo, loop)
103 execution = by_id.get(scan_id) if scan_id is not None else None
104 if execution is None:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status="none",
110 live=False,
111 last_tick=None,
112 )
113 )
114 else:
115 rows.append(
116 ScanLoop(
117 repo=repo,
118 loop=loop.value,
119 status=execution.status,
120 live=execution.status in _LIVE_STATUSES,
121 last_tick=execution.start,
122 )
123 )
124 return tuple(rows)
125 
126 
127def _newer(a: datetime | None, b: datetime | None) -> bool:
128 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
129 if a is None:
130 return False
131 if b is None:
132 return True
133 return a > b
134 
135 
136def _bump_rows(
137 now: datetime,
138 prs: tuple[GithubPr, ...],
139 bumps: tuple[BumpExecution, ...],
140 outcomes: dict[tuple[str, int], str],
141) -> tuple[BumpRow, ...]:
142 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number.
143 
144 ``outcomes`` carries the post-merge signal (``held`` / ``broke`` /
145 ``reverted`` / ``unknown``) per recently-merged ``(repo, number)``; it is
146 absent for older or non-merged PRs, which leaves ``post_merge`` ``None``.
147 """
148 # Keyed on (repo, PR number), not the bare number: Temporal lists every
149 # repo's bumps in the namespace, so two repos that each have a PR #N would
150 # otherwise cross-attribute one's verdict/CI onto the other's row.
151 by_pr: dict[tuple[str, int], BumpExecution] = {
152 (bump.repo, bump.pr_number): bump
153 for bump in bumps
154 if bump.repo is not None and bump.pr_number is not None
155 }
156 rows: list[BumpRow] = []
157 for pr in prs:
158 execution = by_pr.get((pr.repo, pr.number))
159 verdict = (execution.verdict if execution else None) or pr.verdict
160 ci = execution.ci if execution else None
161 ttm = _minutes_between(pr.opened_at, pr.merged_at)
162 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
163 # A removal carries no version: show "unused" in the target column and
164 # no from-version, so a dead-code row reads "left-pad -> unused" rather
165 # than the bump-shaped "left-pad -> ?".
166 is_removal = pr.loop == Loop.DEAD_CODE.value
167 rows.append(
168 BumpRow(
169 repo=pr.repo,
170 loop=pr.loop,
171 package=pr.package or "?",
172 from_version=None if is_removal else pr.from_version,
173 to_version="unused" if is_removal else (pr.to_version or "?"),
174 state=pr.state,
175 verdict=verdict,
176 ci=ci,
177 pr_number=pr.number,
178 pr_url=pr.url,
179 opened_at=pr.opened_at,
180 merged_at=pr.merged_at,
181 ttm_minutes=ttm,
182 age_hours=age,
183 post_merge=outcomes.get((pr.repo, pr.number)),
184 env=pr.env,
185 )
186 )
187 rows.sort(key=_opened_sort_key, reverse=True)
188 return tuple(rows)
189 
190 
191def _opened_sort_key(row: BumpRow) -> float:
192 """Sort key putting the most recently opened PR first (unknown last)."""
193 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
194 
195 
196def _minutes_between(
197 start: datetime | None, end: datetime | None
198) -> float | None:
199 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
200 if start is None or end is None:
201 return None
202 return round((end - start).total_seconds() / 60, 1)
203 
204 
205def _hours_between(
206 start: datetime | None, end: datetime | None
207) -> float | None:
208 """Hours from ``start`` to ``end``, or ``None``."""
209 if start is None or end is None:
210 return None
211 return round((end - start).total_seconds() / 3600, 1)
212 
213 
214def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
215 """Counts, merge rate, and median time-to-merge from the bump rows."""
216 merged = [r for r in rows if r.state == "merged"]
217 closed = sum(1 for r in rows if r.state == "closed")
218 open_now = sum(1 for r in rows if r.state == "open")
219 decided = len(merged) + closed
220 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
221 return TrackRecord(
222 opened=len(rows),
223 merged=len(merged),
224 closed_unmerged=closed,
225 open_now=open_now,
226 merge_rate=(len(merged) / decided) if decided else None,
227 median_ttm_minutes=_median(ttms),
228 )
229 
230 
231def _verification(rows: tuple[BumpRow, ...]) -> Verification:
232 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
233 passed = sum(1 for r in rows if r.ci == "passed")
234 failed = sum(1 for r in rows if r.ci == "failed")
235 absent = sum(1 for r in rows if r.ci == "absent")
236 timed_out = sum(1 for r in rows if r.ci == "timed_out")
237 unknown = sum(1 for r in rows if r.ci is None)
238 return Verification(
239 passed=passed,
240 failed=failed,
241 absent=absent,
242 timed_out=timed_out,
243 unknown=unknown,
244 oracle_existed=passed + failed,
245 with_reading=passed + failed + absent + timed_out,
246 )
247 
248 
249def _reliability(rows: tuple[BumpRow, ...], window_days: int) -> Reliability:
250 """The post-merge outcome breakdown — did the merges hold? (coarse).
251 
252 Reads only the ``post_merge`` tag the outcome reader set: ``unknown`` means
253 an in-window merge we could not classify (no branch oracle / aged out), kept
254 distinct from a held one; ``None`` (older / non-merged) is not counted here.
255 The defect rate is a floor — manual and bundled reverts are invisible.
256 """
257 held = sum(1 for r in rows if r.post_merge == "held")
258 broke = sum(1 for r in rows if r.post_merge == "broke")
259 reverted = sum(1 for r in rows if r.post_merge == "reverted")
260 unverified = sum(1 for r in rows if r.post_merge == "unknown")
261 determined = held + broke + reverted
262 return Reliability(
263 held=held,
264 broke=broke,
265 reverted=reverted,
266 unverified=unverified,
267 determined=determined,
268 defect_rate=((broke + reverted) / determined) if determined else None,
269 window_days=window_days,
270 )
271 
272 
273def _probes(canary_rows: tuple[BumpRow, ...]) -> Probes:
274 """Tally the adversarial canary probes — caught / escaped / pending.
275 
276 These rows are the synthetic bad bumps; they are scored on the strict bar
277 (a canary must never merge) and kept out of every genuine bearing.
278 """
279 scored = [score_probe(r.state) for r in canary_rows]
280 return Probes(
281 caught=sum(1 for s in scored if s == "caught"),
282 escaped=sum(1 for s in scored if s == "escaped"),
283 pending=sum(1 for s in scored if s == "pending"),
284 total=len(scored),
285 )
286 
287 
288def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
289 """The verdict mix plus the two calibration cells worth flagging."""
290 clean = sum(1 for r in rows if r.verdict == "clean")
291 risky = sum(1 for r in rows if r.verdict == "risky")
292 unknown = sum(1 for r in rows if r.verdict == "unknown")
293 none = sum(1 for r in rows if r.verdict is None)
294 clean_but_failed = sum(
295 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
296 )
297 flagged_but_passed = sum(
298 1
299 for r in rows
300 if r.verdict in ("risky", "unknown") and r.ci == "passed"
301 )
302 return Judgment(
303 clean=clean,
304 risky=risky,
305 unknown=unknown,
306 none=none,
307 clean_but_failed=clean_but_failed,
308 flagged_but_passed=flagged_but_passed,
309 )
310 
311 
312def _decided_at(row: BumpRow) -> datetime | None:
313 """When a decided PR was decided — merge time, else open time as a proxy.
314 
315 A merged PR has an exact merge instant; a closed-unmerged one does not (the
316 issues list froot reads carries no close timestamp), so it is windowed by
317 when it opened. The proxy only nudges a closed PR's window membership by its
318 own lifetime — close enough for a recency window measured in months.
319 """
320 return row.merged_at or row.opened_at
321 
322 
323def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
324 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
325 if when is None:
326 return False
327 return (now - when).total_seconds() <= window_days * 86400.0
328 
329 
330def _class_gates(
331 now: datetime,
332 rows: tuple[BumpRow, ...],
333 repos: tuple[str, ...],
334 loops: tuple[Loop, ...],
335 policy: AutonomyPolicy,
336 environment: str,
337) -> tuple[ClassGate, ...]:
338 """The earned-autonomy standing of each (repo, loop) class.
339 
340 Counts only PRs *decided within the window* — trust is recent, not lifetime
341 (§2.11) — *and* earned under the current ``environment`` (§3.7's conditional
342 property): a PR opened under a different judge model no longer counts, so a
343 model swap resets the class. ``prior_env_decided`` keeps that reset legible.
344 The budget figures (approvals / reclaim per week) translate the record into
345 the steward-time MHE meters (§3.6): what the class costs now, and what
346 moving its gate would hand back.
347 """
348 weeks = max(policy.window_days / 7.0, 1.0)
349 gates: list[ClassGate] = []
350 for loop in loops:
351 for repo in repos:
352 in_window = [
353 r
354 for r in rows
355 if r.repo == repo
356 and r.loop == loop.value
357 and r.state in ("merged", "closed")
358 and _within(_decided_at(r), now, policy.window_days)
359 ]
360 # An empty ``environment`` means "don't filter" (none configured);
361 # otherwise only PRs stamped with the current one count.
362 decided_rows = [
363 r for r in in_window if not environment or r.env == environment
364 ]
365 prior_env_decided = len(in_window) - len(decided_rows)
366 merged_rows = [r for r in decided_rows if r.state == "merged"]
367 decided = len(decided_rows)
368 merged = len(merged_rows)
369 # The post-merge defect bearing, per class (§3.8): confirmed-held
370 # outcomes and how many of those went bad. Independent of the rate.
371 determined = sum(
372 1
373 for r in merged_rows
374 if r.post_merge in ("held", "broke", "reverted")
375 )
376 defects = sum(
377 1 for r in merged_rows if r.post_merge in ("broke", "reverted")
378 )
379 earned, blocker = class_earned(
380 decided=decided,
381 merged=merged,
382 determined=determined,
383 defects=defects,
384 policy=policy,
385 )
386 # Reclaim is the budget a gate *move* hands back — so it is zero
387 # until the class has actually earned the move. Counting the
388 # clean-and-green merges of an un-earned class would imply savings
389 # the gate would refuse (every PR stays held at "class not earned").
390 reclaimable = (
391 sum(
392 1
393 for r in merged_rows
394 if r.verdict == "clean" and r.ci == "passed"
395 )
396 if earned
397 else 0
398 )
399 gates.append(
400 ClassGate(
401 repo=repo,
402 loop=loop.value,
403 decided=decided,
404 merged=merged,
405 merge_rate=(merged / decided) if decided else None,
406 determined=determined,
407 defects=defects,
408 defect_rate=(
409 (defects / determined) if determined else None
410 ),
411 prior_env_decided=prior_env_decided,
412 earned=earned,
413 blocker=blocker,
414 approvals_per_week=round(merged / weeks, 2),
415 reclaim_per_week=round(reclaimable / weeks, 2),
416 window_days=policy.window_days,
417 )
418 )
419 return tuple(gates)
420 
421 
422def earned_now(
423 now: datetime,
424 prs: tuple[GithubPr, ...],
425 outcomes: dict[tuple[str, int], str],
426 repo: str,
427 loop: Loop,
428 policy: AutonomyPolicy,
429 environment: str,
430) -> bool:
431 """Whether one (repo, loop) class has earned its gate, from live data.
432 
433 The exact computation the dashboard's class-gate panel shows, exposed so the
434 acting gate (the loop) reuses it rather than duplicating the windowing,
435 environment filter, and triangulation. Pure over already-fetched data.
436 """
437 rows = _bump_rows(now, prs, (), outcomes)
438 gates = _class_gates(now, rows, (repo,), (loop,), policy, environment)
439 return any(g.earned for g in gates)
440 
441 
442def _gate(
443 rows: tuple[BumpRow, ...],
444 gates: tuple[ClassGate, ...],
445 policy: AutonomyPolicy,
446) -> tuple[BumpRow, ...]:
447 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
448 
449 The verdict is the gate's: would this PR auto-merge under its class's grant
450 — the loop's real decision where the repo is allowlisted, advisory (the
451 shadow gate) where it is not. The reason is the grant met, or the first
452 blocker to fix.
453 """
454 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
455 open_rows = [r for r in rows if r.state == "open"]
456 open_rows.sort(
457 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
458 reverse=True,
459 )
460 annotated: list[BumpRow] = []
461 for row in open_rows:
462 earned, blocker = earned_by_class.get(
463 (row.repo, row.loop), (False, "no record for this class")
464 )
465 verdict = pr_autonomy(
466 repo=row.repo,
467 verdict=row.verdict,
468 ci=row.ci,
469 earned=earned,
470 blocker=blocker,
471 policy=policy,
472 )
473 annotated.append(
474 row.model_copy(
475 update={
476 "would_auto_merge": verdict.would_merge,
477 "held_reason": (
478 None if verdict.would_merge else verdict.reason
479 ),
480 }
481 )
482 )
483 return tuple(annotated)
484 
485 
486def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
487 """Bump loops that did not close, newest first."""
488 failures = [
489 Failure(
490 workflow_id=bump.workflow_id,
491 kind=bump.status,
492 reason=bump.reason,
493 when=bump.close,
494 )
495 for bump in bumps
496 if bump.status in _FAILURE_STATUSES
497 ]
498 failures.sort(
499 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
500 reverse=True,
501 )
502 return tuple(failures)
503 
504 
505def _loop_title(loop: Loop) -> str:
506 """The human tab title for a loop key (``dependency-patch`` -> ...)."""
507 return loop.value.capitalize()
508 
509 
510def _failure_loop(workflow_id: str, loops: tuple[Loop, ...]) -> Loop | None:
511 """Which loop a failed bump belongs to, from its workflow id.
512 
513 Non-default loops carry their name as an id segment
514 (``froot-bump-security-patch-…``); dependency-patch is segmentless
515 (``froot-bump-…``), so it is the fallback once the specific loops miss.
516 """
517 for loop in loops:
518 if loop is not Loop.DEPENDENCY_PATCH and workflow_id.startswith(
519 f"froot-bump-{loop.value}-"
520 ):
521 return loop
522 if workflow_id.startswith("froot-bump-"):
523 return next(
524 (loop for loop in loops if loop is Loop.DEPENDENCY_PATCH), None
525 )
526 return None
527 
528 
529def _bump_loops(
530 now: datetime,
531 rows: tuple[BumpRow, ...],
532 canary_rows: tuple[BumpRow, ...],
533 failures: tuple[Failure, ...],
534 scans: tuple[ScanExecution, ...],
535 repos: tuple[str, ...],
536 loops: tuple[Loop, ...],
537 policy: AutonomyPolicy,
538 environment: str,
539 scan_interval_seconds: int,
540 reliability_window_days: int,
541) -> tuple[LoopView, ...]:
542 """One self-contained :class:`LoopView` per loop — the per-loop tabs.
543 
544 Partitions the already-computed bump rows by loop and runs the very same
545 aggregates the combined view uses, so each loop's tab is the whole dashboard
546 scoped to one trust class (§3.9). Failures are attributed by workflow id.
547 """
548 views: list[LoopView] = []
549 for loop in loops:
550 loop_rows = tuple(r for r in rows if r.loop == loop.value)
551 loop_canary = tuple(r for r in canary_rows if r.loop == loop.value)
552 loop_gates = _class_gates(
553 now, loop_rows, repos, (loop,), policy, environment
554 )
555 loop_failures = tuple(
556 f for f in failures if _failure_loop(f.workflow_id, loops) is loop
557 )
558 views.append(
559 LoopView(
560 loop=loop.value,
561 title=_loop_title(loop),
562 icon=registry.get(loop).dashboard_icon,
563 scan_loops=_scan_loops(repos, (loop,), scans),
564 scan_interval_seconds=scan_interval_seconds,
565 track_record=_track_record(loop_rows),
566 class_gates=loop_gates,
567 verification=_verification(loop_rows),
568 reliability=_reliability(loop_rows, reliability_window_days),
569 probes=_probes(loop_canary),
570 judgment=_judgment(loop_rows),
571 gate=_gate(loop_rows, loop_gates, policy),
572 bumps=loop_rows,
573 failures=loop_failures,
574 )
575 )
576 return tuple(views)
577 
578 
579# Which per-repo-loop-id namer each advisory loop uses. The naming is bespoke
580# (the loops' workflows are still their own), so the join keeps a tiny dispatch;
581# the per-PR prefix is then a uniform transform of the loop id (below).
582_ADVISORY_LOOP_ID = {
583 Loop.DETERMINISM_REVIEW: review_workflow_id,
584 Loop.A11Y_REVIEW: a11y_review_workflow_id,
586 
587 
588def _advisory_loop_id(loop: Loop, repo: str) -> str | None:
589 """The deterministic per-repo loop id for ``repo``, if valid and known."""
590 namer = _ADVISORY_LOOP_ID.get(loop)
591 if namer is None:
592 return None
593 match RepoRef.parse(repo):
594 case Ok(ref):
595 return namer(TargetRepo(repo=ref))
596 case _:
597 return None
598 
599 
600def _pr_advisory_prefix(loop: Loop, repo: str) -> str | None:
601 """The id prefix every per-PR review of ``(loop, repo)`` shares (join key).
602 
603 Each per-PR id is the loop id with ``froot-`` widened to ``froot-pr-``, then
604 the ``-<pr>-<sha>`` tail; the shared prefix is that, sans tail.
605 """
606 loop_id = _advisory_loop_id(loop, repo)
607 if loop_id is None:
608 return None
609 return "froot-pr-" + loop_id.removeprefix("froot-") + "-"
610 
611 
612def _attribute_advisory_repo(
613 loop: Loop, workflow_id: str, repos: tuple[str, ...]
614) -> str | None:
615 """The configured repo a per-PR-review id belongs to (longest prefix)."""
616 best: str | None = None
617 best_len = -1
618 for repo in repos:
619 prefix = _pr_advisory_prefix(loop, repo)
620 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
621 best, best_len = repo, len(prefix)
622 return best
623 
624 
625def _advisory_loops(
626 loop: Loop, repos: tuple[str, ...], execs: tuple[AdvisoryExecution, ...]
627) -> tuple[AdvisoryLoop, ...]:
628 """One liveness row per repo that actually runs this advisory loop.
629 
630 Executions are scoped to the Temporal repos, so a configured repo with no
631 such loop is omitted rather than shown as a dead one.
632 """
633 by_id: dict[str, AdvisoryExecution] = {}
634 for candidate in execs:
635 if candidate.loop != loop:
636 continue
637 current = by_id.get(candidate.workflow_id)
638 if current is None or _newer(candidate.start, current.start):
639 by_id[candidate.workflow_id] = candidate
640 loops: list[AdvisoryLoop] = []
641 for repo in repos:
642 loop_id = _advisory_loop_id(loop, repo)
643 execution = by_id.get(loop_id) if loop_id is not None else None
644 if execution is None:
645 continue
646 loops.append(
647 AdvisoryLoop(
648 repo=repo,
649 status=execution.status,
650 live=execution.status in _LIVE_STATUSES,
651 last_tick=execution.start,
652 )
653 )
654 return tuple(loops)
655 
656 
657def _advisory_rows(
658 loop: Loop,
659 pr_execs: tuple[PrAdvisoryExecution, ...],
660 repos: tuple[str, ...],
661) -> tuple[AdvisoryRow, ...]:
662 """Project each per-PR review of this loop into a row, newest first."""
663 rows: list[AdvisoryRow] = []
664 for execution in pr_execs:
665 if execution.loop != loop:
666 continue
667 repo = _attribute_advisory_repo(loop, execution.workflow_id, repos)
668 pr_url = (
669 f"https://github.com/{repo}/pull/{execution.pr_number}"
670 if repo is not None and execution.pr_number is not None
671 else None
672 )
673 rows.append(
674 AdvisoryRow(
675 repo=repo or "?",
676 pr_number=execution.pr_number,
677 pr_url=pr_url,
678 head_sha=execution.head_sha,
679 findings=execution.findings,
680 detail=execution.detail,
681 comment_url=execution.comment_url,
682 status=execution.status,
683 reviewed_at=execution.close or execution.start,
684 )
685 )
686 rows.sort(
687 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
688 reverse=True,
689 )
690 return tuple(rows)
691 
692 
693def _advisory_record(
694 loops: tuple[AdvisoryLoop, ...], rows: tuple[AdvisoryRow, ...]
695) -> AdvisoryRecord:
696 """Counts over this loop's completed reviews."""
697 completed = [r for r in rows if r.status == "completed"]
698 flagged = sum(1 for r in completed if r.findings > 0)
699 findings = sum(r.findings for r in completed)
700 return AdvisoryRecord(
701 reviewed=len(completed),
702 flagged=flagged,
703 clean=len(completed) - flagged,
704 findings=findings,
705 repos_covered=len(loops),
706 )
707 
708 
709def _advisory_views(
710 repos: tuple[str, ...],
711 execs: tuple[AdvisoryExecution, ...],
712 pr_execs: tuple[PrAdvisoryExecution, ...],
713 intervals: dict[Loop, int],
714) -> tuple[AdvisoryView, ...]:
715 """One tab per registered advisory loop, presentation from the registry.
716 
717 The emit-signal specs drive both which tabs exist and how each is labelled
718 (icon, title), so registering an advisory loop is all it takes to surface
719 its tab — nothing here changes.
720 """
721 views: list[AdvisoryView] = []
722 for spec in registry.all_specs():
723 tail = spec.tail
724 if not isinstance(tail, registry.AdvisoryTail):
725 continue
726 loop = spec.loop
727 loops = _advisory_loops(loop, repos, execs)
728 rows = _advisory_rows(loop, pr_execs, repos)
729 views.append(
730 AdvisoryView(
731 loop=loop,
732 icon=spec.dashboard_icon,
733 title=tail.panel_title,
734 interval_seconds=intervals.get(loop, 0),
735 loops=loops,
736 record=_advisory_record(loops, rows),
737 rows=rows,
738 )
739 )
740 return tuple(views)
741 
⋯ 120 lines hidden (lines 742–861)
742 
743def _sources(
744 github_error: str | None,
745 github_count: int,
746 temporal_error: str | None,
747 temporal_count: int,
748 telemetry: RunTelemetry,
749 clickhouse_error: str | None,
750) -> tuple[SourceHealth, ...]:
751 """Per-source health for the header strip."""
752 clickhouse_ok = clickhouse_error is None
753 if clickhouse_error == "off":
754 clickhouse_detail = "off"
755 elif clickhouse_error is not None:
756 clickhouse_detail = clickhouse_error
757 else:
758 spans = telemetry.total_spans
759 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
760 return (
761 SourceHealth(
762 name="github",
763 ok=github_error is None,
764 detail=github_error or f"{github_count} PRs",
765 ),
766 SourceHealth(
767 name="temporal",
768 ok=temporal_error is None,
769 detail=temporal_error or f"{temporal_count} workflows",
770 ),
771 SourceHealth(
772 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
773 ),
774 )
775 
776 
777def assemble(
778 *,
779 now: datetime,
780 repos: tuple[str, ...],
781 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
782 policy: AutonomyPolicy = _DEFAULT_POLICY,
783 scan_interval_seconds: int,
784 advisory_intervals: dict[Loop, int] | None = None,
785 github: tuple[tuple[GithubPr, ...], str | None],
786 temporal: tuple[
787 tuple[
788 tuple[ScanExecution, ...],
789 tuple[BumpExecution, ...],
790 tuple[AdvisoryExecution, ...],
791 tuple[PrAdvisoryExecution, ...],
792 ],
793 str | None,
794 ],
795 telemetry: tuple[RunTelemetry, str | None],
796 outcomes: dict[tuple[str, int], str] | None = None,
797 reliability_window_days: int = 90,
798 environment: str = "",
799) -> DashboardModel:
800 """Build the whole view from the readers' ``(data, error)`` outputs.
801 
802 ``outcomes`` is the post-merge signal per ``(repo, number)`` from the
803 outcome reader (best-effort); absent for a merge older than the window.
804 """
805 prs, github_error = github
806 (
807 (scans, bumps, advisory, pr_advisory),
808 temporal_error,
809 ) = temporal
810 run_telemetry, clickhouse_error = telemetry
811 
812 all_rows = _bump_rows(now, prs, bumps, outcomes or {})
813 # Canary probes are synthetic bad bumps — keep them out of every genuine
814 # bearing (track record, defect rate, gates) so a planted failure can never
815 # pollute the real reputation; they get their own tally.
816 canary_rows = tuple(r for r in all_rows if is_canary(r.to_version))
817 rows = tuple(r for r in all_rows if not is_canary(r.to_version))
818 class_gates = _class_gates(now, rows, repos, loops, policy, environment)
819 all_failures = _failures(bumps)
820 bump_loops = _bump_loops(
821 now,
822 rows,
823 canary_rows,
824 all_failures,
825 scans,
826 repos,
827 loops,
828 policy,
829 environment,
830 scan_interval_seconds,
831 reliability_window_days,
832 )
833 advisory_views = _advisory_views(
834 repos, advisory, pr_advisory, advisory_intervals or {}
835 )
836 return DashboardModel(
837 generated_at=now,
838 repos_configured=repos,
839 scan_interval_seconds=scan_interval_seconds,
840 sources=_sources(
841 github_error,
842 len(prs),
843 temporal_error,
844 len(scans) + len(bumps) + len(advisory) + len(pr_advisory),
845 run_telemetry,
846 clickhouse_error,
847 ),
848 scan_loops=_scan_loops(repos, loops, scans),
849 track_record=_track_record(rows),
850 class_gates=class_gates,
851 verification=_verification(rows),
852 reliability=_reliability(rows, reliability_window_days),
853 probes=_probes(canary_rows),
854 judgment=_judgment(rows),
855 gate=_gate(rows, class_gates, policy),
856 bumps=rows,
857 failures=all_failures,
858 advisory=advisory_views,
859 telemetry=run_telemetry,
860 bump_loops=bump_loops,
861 )

The renderer loops the views

One _advisory_panel replaces the two near-identical panels; its icon and title come off the view (so off the spec). The caption, finding noun, and detail column are generic now.

src/froot/dashboard/render.py · 902 lines
src/froot/dashboard/render.py902 lines · Python
⋯ 689 lines hidden (lines 1–689)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript (tabs are CSS-only, via hidden radio
4inputs), and the page makes no network request of its own — it is a static,
5full-screen projection of an already-computed
6:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
7HTML-escaped at the boundary.
8 
9The shape is gate-first: each loop is a tab whose hero is the *gate* — a small
10flow of the four trust bearings into the earned/hold decision — over a compact
11metric grid and foldable detail. Each loop is a distinct trust class (§3.9), so
12each tab is the whole dashboard scoped to one loop; determinism-review and the
13cross-cutting run-telemetry get their own tabs.
14"""
15 
16from __future__ import annotations
17 
18from datetime import UTC, datetime, timedelta
19from html import escape
20from typing import TYPE_CHECKING
21 
22if TYPE_CHECKING:
23 from froot.dashboard.model import (
24 AdvisoryRow,
25 AdvisoryView,
26 BumpRow,
27 ClassGate,
28 DashboardModel,
29 LoopView,
30 RunTelemetry,
31 )
32 
33_LIGHT = (
34 "--fg:#1b1f24;--mut:#586273;--faint:#8b94a3;--line:#e9ebef;--hair:#d6dae1;"
35 "--bg:#fcfcfd;--tint:#f5f6f9;--accent:#7c3aed;--ok:#2f7d4f;--warn:#8a6011;"
36 "--bad:#b23a2e"
38_DARK = (
39 "--fg:#e7e9ee;--mut:#9aa3b2;--faint:#6b7484;--line:#22262e;--hair:#333a45;"
40 "--bg:#0b0d10;--tint:#14171c;--accent:#a78bfa;--ok:#4cae6e;--warn:#d6a23f;"
41 "--bad:#e07a6e"
43# Editorial-minimal "read-model" idiom: structure from hairlines + whitespace +
44# type, never from box fills; semantic color rides on dots and the value text,
45# not on filled chips. Light is the default; a toggle (data-theme) forces either
46# theme, and bare system preference is honored when nothing is forced.
47_CSS = f"""
48:root{{{_LIGHT};
49--serif:"Iowan Old Style",Palatino,Georgia,"Times New Roman",ui-serif,serif;
50--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
51@media(prefers-color-scheme:dark){{:root:not([data-theme]){{{_DARK}}}}}
52:root[data-theme=dark]{{{_DARK}}}
53*{{box-sizing:border-box}}
54body{{margin:0;background:var(--bg);color:var(--fg);
55font:14px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
56-webkit-font-smoothing:antialiased}}
57main{{padding:0 clamp(16px,3vw,46px) 72px}}
58a{{color:var(--accent);text-decoration:none}}a:hover{{text-decoration:underline}}
59.mono{{font-family:var(--mono);font-size:.92em}}
60.serif{{font-family:var(--serif)}}
61.mut{{color:var(--mut)}}.ok{{color:var(--ok)}}.warn{{color:var(--warn)}}
62.bad{{color:var(--bad)}}.acc{{color:var(--accent)}}
63.num{{font-variant-numeric:tabular-nums}}
64/* status dot — the one carrier of valence besides the value text itself */
65.dot{{display:inline-block;width:8px;height:8px;border-radius:50%;flex:none}}
66.dot.ok{{background:var(--ok)}}.dot.warn{{background:var(--warn)}}
67.dot.bad{{background:var(--bad)}}.dot.mute{{background:var(--faint)}}
68.dot.acc{{background:var(--accent)}}
69/* header */
70header{{display:flex;align-items:center;flex-wrap:wrap;gap:9px 22px;
71padding:26px 0 18px;border-bottom:1px solid var(--line)}}
72h1{{font-family:var(--serif);font-size:23px;margin:0;letter-spacing:-.01em;
73font-weight:600;display:inline-flex;align-items:center;gap:9px}}
74.ic{{width:15px;height:15px;flex:none}}
75.mark{{width:18px;height:18px;flex:none;color:var(--accent)}}
76.hstatus{{display:flex;align-items:center;gap:7px;font-size:13px}}
77.hmeta{{margin-left:auto;display:flex;flex-wrap:wrap;align-items:center;
78gap:7px 16px;color:var(--mut);font-size:12px}}
79.srcs{{display:inline-flex;flex-wrap:wrap;align-items:center;gap:5px 14px}}
80.src{{display:inline-flex;align-items:center;gap:6px}}
81.themetgl{{background:transparent;border:1px solid var(--line);border-radius:
82999px;width:30px;height:30px;cursor:pointer;color:var(--mut);font-size:14px;
83line-height:1;display:inline-flex;align-items:center;justify-content:center;
84transition:border-color .14s,color .14s}}
85.themetgl:hover{{border-color:var(--fg);color:var(--fg)}}
86/* tabs (CSS-only radios) */
87.tabin{{position:absolute;opacity:0;pointer-events:none}}
88nav.tabbar{{display:flex;flex-wrap:wrap;gap:2px 6px;margin:16px 0 0;
89border-bottom:1px solid var(--line)}}
90nav.tabbar label{{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
91padding:10px 4px;margin:0 12px -1px 0;font-size:13.5px;font-weight:600;
92color:var(--mut);border-bottom:2px solid transparent;white-space:nowrap;
93transition:color .14s}}
94nav.tabbar label:hover{{color:var(--fg)}}
95nav.tabbar label.aside{{margin-left:auto}}
96nav.tabbar label .badge{{font-family:var(--mono);font-weight:500;font-size:11px;
97color:var(--faint)}}
98.panel{{display:none;padding:26px 0 0;animation:fade .16s ease}}
99@keyframes fade{{from{{opacity:.45}}to{{opacity:1}}}}
100/* gate hero */
101.hero{{display:grid;align-items:start;margin:0 0 4px;gap:22px 40px;
102grid-template-columns:minmax(330px,1.05fr) minmax(300px,1fr)}}
103@media(max-width:780px){{.hero{{grid-template-columns:1fr}}}}
104.heroh{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
105color:var(--mut);font-weight:600;margin:0 0 14px;padding-bottom:7px;
106border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}}
107.gateflow{{display:flex;align-items:center;gap:14px;flex-wrap:wrap}}
108.bearings{{display:flex;flex-direction:column;min-width:172px;flex:1 1 172px}}
109.bearing{{display:flex;align-items:center;justify-content:space-between;gap:12px;
110padding:7px 0;border-bottom:1px solid var(--line);font-size:12.5px}}
111.bearing:last-child{{border-bottom:0}}
112.bearing .bl{{color:var(--mut)}}
113.bearing .bv{{display:inline-flex;align-items:center;gap:7px;font-weight:600;
114font-variant-numeric:tabular-nums}}
115.bearing.armed .bv{{color:var(--faint);font-weight:500}}
116.flowarrow{{color:var(--hair);font-size:17px;line-height:1}}
117.gatenode{{flex:0 0 auto;text-align:center;padding:2px 6px}}
118.gatenode .gl{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
119color:var(--mut);font-weight:600}}
120.gatenode .gv{{font-family:var(--serif);font-size:30px;font-weight:600;
121line-height:1.1;color:var(--fg);font-variant-numeric:tabular-nums}}
122.gatenode .gs{{font-size:11px;color:var(--faint)}}
123.outcome{{flex:0 0 auto;display:flex;flex-direction:column;gap:3px}}
124.outcome .ot{{display:flex;align-items:center;gap:7px;font-size:15px;
125font-weight:700;letter-spacing:.02em}}
126.outcome .os{{font-size:11px;color:var(--faint)}}
127.outcome.act .ot{{color:var(--ok)}}
128.outcome.hold .ot{{color:var(--mut)}}
129.caption{{color:var(--mut);font-size:12px;margin:14px 0 0;line-height:1.5;
130max-width:62ch}}
131/* per-class table inside the hero */
132.classes{{width:100%;border-collapse:collapse;font-size:12.5px}}
133.classes th,.classes td{{text-align:left;padding:7px 12px 7px 0;
134border-bottom:1px solid var(--line);vertical-align:baseline}}
135.classes th{{color:var(--faint);font-weight:600;font-size:10.5px;
136text-transform:uppercase;letter-spacing:.06em}}
137.classes td.r{{font-variant-numeric:tabular-nums}}
138.pill{{display:inline-flex;align-items:center;gap:6px;font-size:12px;
139font-weight:600}}
140.pill.ok{{color:var(--ok)}}.pill.hold{{color:var(--mut);font-weight:500}}
141/* metric strip — bare stats, no boxes; a hairline frames the row */
142.cards{{display:grid;gap:20px 30px;margin:26px 0 0;padding:18px 0 0;
143border-top:1px solid var(--line);
144grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
145.card .n{{font-size:26px;font-weight:600;line-height:1.05;
146font-variant-numeric:tabular-nums;letter-spacing:-.01em}}
147.card .l{{color:var(--mut);font-size:11px;text-transform:uppercase;
148letter-spacing:.05em;font-weight:600;margin-top:5px}}
149.card .x{{color:var(--faint);font-size:11.5px;margin-top:3px}}
150/* section + detail */
151.sec{{margin:30px 0 0}}
152.sech{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
153color:var(--mut);font-weight:600;margin:0 0 12px;padding-bottom:7px;
154border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}}
155.sech .n{{color:var(--faint);font-weight:500;letter-spacing:0;
156text-transform:none}}
157details.fold{{margin:14px 0 0;border-top:1px solid var(--line)}}
158details.fold>summary{{cursor:pointer;list-style:none;padding:11px 0;
159font-size:12px;font-weight:600;color:var(--mut);display:flex;
160align-items:center;gap:9px}}
161details.fold>summary:hover{{color:var(--fg)}}
162details.fold>summary::-webkit-details-marker{{display:none}}
163details.fold>summary::before{{content:"\\25B8";color:var(--hair);font-size:10px}}
164details.fold[open]>summary::before{{content:"\\25BE"}}
165details.fold>summary .c{{color:var(--faint);font-weight:500}}
166details.fold .body{{padding:2px 0 16px}}
167table.data{{width:100%;border-collapse:collapse;font-size:12.5px}}
168table.data th,table.data td{{text-align:left;padding:7px 14px 7px 0;
169border-bottom:1px solid var(--line);vertical-align:baseline}}
170table.data th{{color:var(--faint);font-weight:600;font-size:10.5px;
171text-transform:uppercase;letter-spacing:.05em}}
172table.data td.r{{font-variant-numeric:tabular-nums;white-space:nowrap}}
173.empty{{color:var(--faint);font-size:13px;padding:16px 0}}
174.t{{font-size:12px;color:var(--mut)}}
175.t.ok{{color:var(--ok)}}.t.warn{{color:var(--warn)}}.t.bad{{color:var(--bad)}}
176.cad{{color:var(--mut);font-size:12px;margin:18px 0 0}}
177.cad b{{color:var(--fg);font-weight:600}}
178footer{{margin:46px 0 0;padding-top:18px;border-top:1px solid var(--line);
179color:var(--mut);font-size:12px;line-height:1.7;max-width:92ch}}
180footer b{{color:var(--fg);font-weight:600}}
181"""
182 
183# The page's only script: set the saved theme before first paint (no flash) and
184# expose the toggle. Inline + tiny, so the page stays one self-contained file
185# with no external request; absent JS, the page simply follows the OS theme.
186_THEME_JS = (
187 "(function(){var k='froot-theme',r=document.documentElement,"
188 "s=localStorage.getItem(k);if(s)r.setAttribute('data-theme',s);"
189 "window.__toggleTheme=function(){var c=r.getAttribute('data-theme')||"
190 "(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');"
191 "var n=c==='dark'?'light':'dark';r.setAttribute('data-theme',n);"
192 "localStorage.setItem(k,n);};})();"
194 
195# A small set of hairline line-icons (inline SVG, stroke = currentColor, so they
196# inherit the muted ink and theme themselves). Anchors for the eye — tabs,
197# section headers, the wordmark — never on the number strip. Paths are 24x24.
198_ICONS = {
199 "package": (
200 '<path d="M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 '
201 '16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z"/>'
202 '<path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'
203 ),
204 "shield": (
205 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
206 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
207 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>'
208 ),
209 "shield-check": (
210 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
211 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
212 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>'
213 ),
214 "search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
215 "activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
216 "layers": (
217 '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 '
218 '3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>'
219 '<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/>'
220 '<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
221 ),
222 "inbox": (
223 '<path d="M22 12h-6l-2 3h-4l-2-3H2"/>'
224 '<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45'
225 '-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>'
226 ),
227 "lock": (
228 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
229 '<path d="M7 11V7a5 5 0 0 1 10 0v4"/>'
230 ),
231 "lock-open": (
232 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
233 '<path d="M7 11V7a5 5 0 0 1 9.9-1"/>'
234 ),
235 "merge": (
236 '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/>'
237 '<path d="M6 21V9a9 9 0 0 0 9 9"/>'
238 ),
239 "scissors": (
240 '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/>'
241 '<path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/>'
242 '<path d="M14.8 14.8 20 20"/>'
243 ),
244 "accessibility": (
245 '<circle cx="16" cy="4" r="1"/><path d="m18 19 1-7-6 1"/>'
246 '<path d="m5 8 3-3 5.5 3-2.36 3.5"/>'
247 '<path d="M4.24 14.5a5 5 0 0 0 6.88 6"/>'
248 '<path d="M13.76 17.5a5 5 0 0 0-6.88-6"/>'
249 ),
251 
252 
253def _icon(name: str, cls: str = "ic") -> str:
254 return (
255 f'<svg class="{cls}" viewBox="0 0 24 24" fill="none" '
256 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
257 f'stroke-linejoin="round" aria-hidden="true">{_ICONS[name]}</svg>'
258 )
259 
260 
261def _wordmark() -> str:
262 """The froot mark: a fruit in the accent, with a single green leaf.
263 
264 Two-tone on purpose — the one spot color is allowed to be richer. The body
265 and stem ride the accent (currentColor); the leaf is the semantic green,
266 so the mark reads as a piece of fruit, not a logo abstraction.
267 """
268 return (
269 '<svg class="mark" viewBox="0 0 24 24" fill="none" '
270 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
271 'stroke-linejoin="round" aria-hidden="true">'
272 '<circle cx="11.5" cy="14.5" r="6.2"/>'
273 '<path d="M11.5 8.3V6.15" stroke="var(--ok)"/>'
274 '<path d="M11.5 6.15c1.4-2 3.5-2.6 5.8-2.2-.4 2.5-2.5 3.7-5.8 2.2z" '
275 'stroke="var(--ok)"/></svg>'
276 )
277 
278 
279_CI_CLASS = {
280 "passed": "ok",
281 "failed": "bad",
282 "absent": "mut",
283 "timed_out": "warn",
285_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
286_POST_MERGE_CLASS = {
287 "held": "ok",
288 "broke": "bad",
289 "reverted": "bad",
290 "unknown": "mut",
292_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
293_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
294 
295 
296# ── small formatters ─────────────────────────────────────────────────────────
297def _aware(when: datetime) -> datetime:
298 return when if when.tzinfo else when.replace(tzinfo=UTC)
299 
300 
301def _ago(when: datetime | None, now: datetime) -> str:
302 if when is None:
303 return ""
304 secs = (now - _aware(when)).total_seconds()
305 if secs < 90:
306 return "just now"
307 mins = secs / 60
308 if mins < 90:
309 return f"{round(mins)}m ago"
310 hours = mins / 60
311 if hours < 36:
312 return f"{round(hours)}h ago"
313 return f"{round(hours / 24)}d ago"
314 
315 
316def _until(when: datetime | None, now: datetime) -> str:
317 if when is None:
318 return ""
319 secs = (_aware(when) - now).total_seconds()
320 if secs <= 0:
321 return "due"
322 mins = secs / 60
323 if mins < 90:
324 return f"~{round(mins)}m"
325 hours = mins / 60
326 if hours < 36:
327 return f"~{round(hours)}h"
328 return f"~{round(hours / 24)}d"
329 
330 
331def _pct(rate: float | None) -> str:
332 return "" if rate is None else f"{rate * 100:.0f}%"
333 
334 
335def _dot(kind: str) -> str:
336 return f'<span class="dot {kind}"></span>'
337 
338 
339def _tag(value: str | None, classes: dict[str, str]) -> str:
340 if value is None:
341 return '<span class="t">—</span>'
342 cls = classes.get(value, "")
343 return f'<span class="t {cls}">{escape(value)}</span>'
344 
345 
346def _pr_link(row: BumpRow) -> str:
347 if row.pr_url is None or row.pr_number is None:
348 return '<span class="mut">—</span>'
349 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
350 
351 
352def _short_id(workflow_id: str) -> str:
353 return escape(workflow_id.removeprefix("froot-bump-"))
354 
355 
356# ── header ───────────────────────────────────────────────────────────────────
357def _alive(model: DashboardModel) -> tuple[str, str]:
358 """Global liveness dot + label across every loop."""
359 advisory_loops = [loop for v in model.advisory for loop in v.loops]
360 live = sum(1 for x in model.scan_loops if x.live) + sum(
361 1 for x in advisory_loops if x.live
362 )
363 total = len(model.scan_loops) + len(advisory_loops)
364 if total == 0:
365 return "mute", "no loops configured"
366 kind = "ok" if live == total else ("warn" if live else "bad")
367 return kind, f"{live}/{total} loops live"
368 
369 
370def _header(model: DashboardModel) -> str:
371 kind, label = _alive(model)
372 sources = "".join(
373 f'<span class="src">{_dot("ok" if s.ok else "bad")}'
374 f"{escape(s.name)}</span>"
375 for s in model.sources
376 )
377 return (
378 "<header>"
379 f"<h1>{_wordmark()}froot</h1>"
380 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
381 f'<span class="hmeta"><span class="srcs">{sources}</span>'
382 '<button class="themetgl" type="button" onclick="__toggleTheme()"'
383 ' title="Toggle light / dark"'
384 ' aria-label="Toggle light or dark theme">&#9680;</button>'
385 "</span></header>"
386 )
387 
388 
389# ── the gate hero (per loop) ─────────────────────────────────────────────────
390def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
391 cls = "bearing armed" if armed else "bearing"
392 dot = _dot("mute" if armed else kind)
393 return (
394 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
395 f'<span class="bv {kind}">{dot}{escape(value)}</span></div>'
396 )
397 
398 
399def _gate_hero(view: LoopView) -> str:
400 t, rel, pr = view.track_record, view.reliability, view.probes
401 earned = sum(1 for g in view.class_gates if g.earned)
402 total = len(view.class_gates)
403 acting = any(r.would_auto_merge for r in view.gate)
404 # The four bearings: rate + defect come from the record; the adversarial
405 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
406 # the merge, so it is shown armed (always-on), not a record figure.
407 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
408 defect_ok = "ok" if not rel.defect_rate else "bad"
409 probe_ok = "ok" if pr.escaped == 0 else "bad"
410 bearings = "".join(
411 (
412 _bearing(
413 "approval rate",
414 _pct(t.merge_rate),
415 rate_ok if t.merge_rate is not None else "mut",
416 ),
417 _bearing(
418 "defect rate",
419 _pct(rel.defect_rate),
420 defect_ok if rel.defect_rate is not None else "mut",
421 ),
422 _bearing(
423 "probe",
424 f"{pr.escaped} escaped" if pr.total else "none",
425 probe_ok if pr.total else "mut",
426 ),
427 _bearing("deep review", "armed", "mut", armed=True),
428 )
429 )
430 if total == 0:
431 node = (
432 '<div class="gatenode"><div class="gl">gate</div>'
433 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
434 )
435 else:
436 node = (
437 f'<div class="gatenode"><div class="gl">earned</div>'
438 f'<div class="gv">{earned}/{total}</div>'
439 '<div class="gs">classes</div></div>'
440 )
441 if acting:
442 out = (
443 f'<div class="outcome act"><div class="ot">{_icon("merge")}'
444 "AUTO-MERGE</div>"
445 '<div class="os">a class is acting now</div></div>'
446 )
447 elif earned:
448 out = (
449 f'<div class="outcome act"><div class="ot">{_icon("lock-open")}'
450 "EARNED</div>"
451 '<div class="os">acts where allowlisted</div></div>'
452 )
453 else:
454 out = (
455 f'<div class="outcome hold"><div class="ot">{_icon("lock")}HOLD'
456 "</div><div class="
457 '"os">building the record</div></div>'
458 )
459 flow = (
460 '<div class="gateflow">'
461 f'<div class="bearings">{bearings}</div>'
462 '<div class="flowarrow">&rarr;</div>'
463 f"{node}"
464 '<div class="flowarrow">&rarr;</div>'
465 f"{out}</div>"
466 )
467 caption = (
468 '<p class="caption">A class earns the gate by triangulation: a high '
469 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
470 "evidence. Two further legs guard the live merge — an adversarial "
471 "<b>probe</b> and an independent <b>deep review</b> at merge. "
472 "Auto-merge is allowlist-gated (off by default).</p>"
473 )
474 return (
475 f'<div><div class="heroh">{_icon("shield-check")}'
476 "Earned autonomy &middot; the gate</div>"
477 f"{flow}{caption}</div>"
478 )
479 
480 
481def _class_table(view: LoopView) -> str:
482 if not view.class_gates:
483 return (
484 f'<div><div class="heroh">{_icon("layers")}Classes</div>'
485 '<div class="empty">No classes yet &mdash; a (repo, loop) '
486 "earns the gate from its own track record.</div></div>"
487 )
488 rows = "".join(_class_row(g) for g in view.class_gates)
489 return (
490 f'<div><div class="heroh">{_icon("layers")}Per-class standing</div>'
491 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
492 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
493 f"<tbody>{rows}</tbody></table></div>"
494 )
495 
496 
497def _class_row(g: ClassGate) -> str:
498 if g.earned:
499 gate = f'<span class="pill ok">{_dot("ok")}earned</span>'
500 else:
501 gate = (
502 f'<span class="pill hold">{_dot("mute")}hold</span> '
503 f'<span class="mut">{escape(g.blocker or "")}</span>'
504 )
505 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
506 budget = (
507 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
508 if g.approvals_per_week
509 else ""
510 )
511 return (
512 f'<tr><td class="mono">{escape(g.repo)}</td>'
513 f'<td class="r">{_pct(g.merge_rate)}</td>'
514 f'<td class="r">{escape(defect)}</td>'
515 f"<td>{gate}</td>"
516 f'<td class="r mut">{escape(budget)}</td></tr>'
517 )
518 
519 
520# ── metric cards (per loop) ──────────────────────────────────────────────────
521def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
522 x = f'<div class="x">{extra}</div>' if extra else ""
523 return (
524 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
525 f'<div class="l">{escape(label)}</div>{x}</div>'
526 )
527 
528 
529def _loop_cards(view: LoopView) -> str:
530 t, v, rel, j, pr = (
531 view.track_record,
532 view.verification,
533 view.reliability,
534 view.judgment,
535 view.probes,
536 )
537 defects = rel.broke + rel.reverted
538 cards = [
539 _card(
540 t.opened,
541 "proposed",
542 f"{t.merged} merged · {t.closed_unmerged} closed",
543 ),
544 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
545 _card(
546 t.open_now,
547 "awaiting you",
548 "open, needs a human",
549 "warn" if t.open_now else "",
550 ),
551 _card(
552 _pct(rel.defect_rate) if rel.determined else "",
553 "defect rate",
554 f"{rel.held} held · {defects} broke",
555 "bad" if defects else "",
556 ),
557 _card(
558 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
559 "CI passed",
560 "the oracle",
561 ),
562 _card(
563 pr.escaped if pr.total else "0",
564 "probes escaped",
565 f"{pr.caught} caught" if pr.total else "no probes yet",
566 "bad" if pr.escaped else "",
567 ),
568 _card(
569 j.clean,
570 "clean verdicts",
571 f"{j.clean_but_failed} mis-judged"
572 if j.clean_but_failed
573 else "judge calibrated",
574 ),
575 _card(rel.held, "merges held", "post-merge, stayed green"),
576 ]
577 return f'<div class="cards">{"".join(cards)}</div>'
578 
579 
580# ── detail tables ────────────────────────────────────────────────────────────
581def _cadence(view: LoopView, now: datetime) -> str:
582 live = sum(1 for s in view.scan_loops if s.live)
583 total = len(view.scan_loops)
584 nxt = ""
585 last = max(
586 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
587 default=None,
588 )
589 if last is not None:
590 due = last + timedelta(seconds=view.scan_interval_seconds)
591 nxt = f" &middot; next {escape(_until(due, now))}"
592 every = round(view.scan_interval_seconds / 3600, 1)
593 return (
594 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
595 f"&middot; every <b>{every}h</b>{nxt}</p>"
596 )
597 
598 
599def _bumps_fold(view: LoopView) -> str:
600 if not view.bumps:
601 return ""
602 rows = "".join(
603 "<tr>"
604 f'<td class="mono">{escape(r.package)}</td>'
605 f'<td class="mono mut">{escape(r.to_version)}</td>'
606 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
607 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
608 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
609 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
610 f"<td>{_pr_link(r)}</td>"
611 "</tr>"
612 for r in view.bumps
613 )
614 return (
615 '<details class="fold"><summary>Bumps '
616 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
617 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
618 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
619 "<th>pr</th></tr></thead>"
620 f"<tbody>{rows}</tbody></table></div></details>"
621 )
622 
623 
624def _failures_fold(view: LoopView) -> str:
625 if not view.failures:
626 return ""
627 rows = "".join(
628 "<tr>"
629 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
630 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
631 f'<td class="mut">{escape(f.reason or "")}</td>'
632 "</tr>"
633 for f in view.failures
634 )
635 return (
636 '<details class="fold"><summary class="bad">Failures '
637 f'<span class="c">{len(view.failures)}</span></summary>'
638 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
639 "<th>kind</th><th>reason</th></tr></thead>"
640 f"<tbody>{rows}</tbody></table></div></details>"
641 )
642 
643 
644# ── panels ───────────────────────────────────────────────────────────────────
645def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
646 queue = _queue_sec(view)
647 return (
648 f'<section class="panel" id="{pid}">'
649 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
650 f"{_loop_cards(view)}"
651 f"{queue}"
652 f"{_cadence(view, now)}"
653 f"{_bumps_fold(view)}{_failures_fold(view)}"
654 "</section>"
655 )
656 
657 
658def _queue_sec(view: LoopView) -> str:
659 if not view.gate:
660 return (
661 f'<div class="sec"><div class="sech">{_icon("inbox")}'
662 'Approval queue <span class="n">empty</span></div>'
663 '<div class="empty">Nothing awaiting you.</div></div>'
664 )
665 rows = "".join(
666 "<tr>"
667 f'<td class="mono">{escape(r.package)}</td>'
668 f'<td class="mono mut">{escape(r.to_version)}</td>'
669 f"<td>{_queue_badge(r)}</td>"
670 f"<td>{_pr_link(r)}</td>"
671 "</tr>"
672 for r in view.gate
673 )
674 return (
675 f'<div class="sec"><div class="sech">{_icon("inbox")}Approval queue '
676 f'<span class="n">{len(view.gate)} yours</span></div>'
677 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
678 "<th>gate</th><th>pr</th></tr></thead>"
679 f"<tbody>{rows}</tbody></table></div>"
680 )
681 
682 
683def _queue_badge(row: BumpRow) -> str:
684 if row.would_auto_merge:
685 return f'<span class="pill ok">{_dot("ok")}would auto-merge</span>'
686 reason = row.held_reason or "held"
687 return f'<span class="mut">held &middot; {escape(reason)}</span>'
688 
689 
690def _advisory_panel(view: AdvisoryView, pid: str) -> str:
691 """One advisory loop's tab, presentation (icon, title) from its spec."""
692 r = view.record
693 live = sum(1 for x in view.loops if x.live)
694 flag = "bad" if r.findings else ""
695 cards = (
696 '<div class="cards">'
697 f"{_card(r.repos_covered, 'repos covered')}"
698 f"{_card(f'{live}/{len(view.loops)}', 'loops live')}"
699 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
700 f"{_card(r.findings, 'findings', 'advisory', flag)}"
701 "</div>"
702 )
703 body = (
704 '<div class="empty">No PRs reviewed yet.</div>'
705 if not view.rows
706 else _advisory_table(view.rows)
707 )
708 note = (
709 '<p class="caption">An advisory loop. froot re-derives each open '
710 "PR's findings every tick and upserts one decaying comment, never "
711 "blocking a merge.</p>"
712 )
713 every = round(view.interval_seconds / 60, 1)
714 cad = (
715 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
716 f"every <b>{every}m</b></p>"
717 )
718 return (
719 f'<section class="panel" id="{pid}">'
720 f'<div class="heroh">{_icon(view.icon)}{escape(view.title)}</div>'
721 f"{note}{cards}{cad}"
722 f'<div class="sec"><div class="sech">Reviews '
723 f'<span class="n">{len(view.rows)}</span></div>{body}</div>'
724 "</section>"
725 )
726 
⋯ 176 lines hidden (lines 727–902)
727 
728def _advisory_table(rows: tuple[AdvisoryRow, ...]) -> str:
729 body = "".join(
730 "<tr>"
731 f'<td class="mono">{escape(row.repo)}</td>'
732 f"<td>{_advisory_pr_link(row)}</td>"
733 f"<td>{_advisory_findings_cell(row)}</td>"
734 f'<td class="mono mut">{escape(", ".join(row.detail) or "")}</td>'
735 "</tr>"
736 for row in rows
737 )
738 return (
739 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
740 "<th>findings</th><th>details</th></tr></thead>"
741 f"<tbody>{body}</tbody></table>"
742 )
743 
744 
745def _advisory_pr_link(row: AdvisoryRow) -> str:
746 if row.pr_url is None or row.pr_number is None:
747 return '<span class="mut">—</span>'
748 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
749 
750 
751def _advisory_findings_cell(row: AdvisoryRow) -> str:
752 if row.findings == 0:
753 return '<span class="ok">clean</span>'
754 label = "finding" if row.findings == 1 else "findings"
755 link = ""
756 if row.comment_url:
757 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
758 return f'<span class="bad">{row.findings} {label}</span>{link}'
759 
760 
761def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
762 tel: RunTelemetry = model.telemetry
763 if not tel.available:
764 body = (
765 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
766 "froot traces in the window.</div>"
767 )
768 else:
769 rows = "".join(
770 "<tr>"
771 f'<td class="mono">{escape(a.name)}</td>'
772 f'<td class="r">{a.count}</td>'
773 f'<td class="r">{a.avg_ms:.0f} ms</td>'
774 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
775 "</tr>"
776 for a in tel.activities
777 )
778 last = f"last {_ago(tel.last_activity, now)}"
779 err = "bad" if tel.error_spans else ""
780 head = (
781 '<div class="cards">'
782 f"{_card(tel.total_spans, 'spans', last)}"
783 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
784 f"{_card(f'{tel.window_days}d', 'window')}</div>"
785 )
786 table = (
787 '<div class="sec"><div class="sech">Activity latency</div>'
788 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
789 "<th>avg</th><th>max</th></tr></thead>"
790 f"<tbody>{rows}</tbody></table></div>"
791 )
792 body = head + table
793 note = (
794 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
795 "(trace-derived, best-effort) — latency per activity across "
796 "every loop.</p>"
797 )
798 return (
799 f'<section class="panel" id="{pid}">'
800 f'<div class="heroh">{_icon("activity")}Run telemetry '
801 "&middot; ClickHouse</div>"
802 f"{note}{body}</section>"
803 )
804 
805 
806# ── tabs + page ──────────────────────────────────────────────────────────────
807def _loop_badge(view: LoopView) -> str:
808 earned = sum(1 for g in view.class_gates if g.earned)
809 if view.class_gates and earned:
810 return f"{earned}/{len(view.class_gates)} earned"
811 if view.track_record.open_now:
812 return f"{view.track_record.open_now} open"
813 return str(view.track_record.opened)
814 
815 
816def page(model: DashboardModel) -> str:
817 """Render the whole dashboard as one self-contained HTML document."""
818 now = model.generated_at
819 # (tab-id, panel-id, icon, label, badge, panel-html)
820 tabs: list[tuple[str, str, str, str, str, str]] = []
821 for i, view in enumerate(model.bump_loops):
822 pid, tid = f"panel-{i}", f"tab-{i}"
823 icon = view.icon
824 tabs.append(
825 (
826 tid,
827 pid,
828 icon,
829 view.title,
830 _loop_badge(view),
831 _loop_panel(view, pid, now),
832 )
833 )
834 for j, advisory in enumerate(model.advisory):
835 pid, tid = f"panel-adv-{j}", f"tab-adv-{j}"
836 tabs.append(
837 (
838 tid,
839 pid,
840 advisory.icon,
841 advisory.title,
842 str(advisory.record.reviewed),
843 _advisory_panel(advisory, pid),
844 )
845 )
846 tabs.append(
847 (
848 "tab-tel",
849 "panel-tel",
850 "activity",
851 "Telemetry",
852 "live" if model.telemetry.available else "off",
853 _telemetry_panel(model, "panel-tel", now),
854 )
855 )
856 
857 inputs, labels, panels, rules = [], [], [], []
858 for idx, (tid, pid, icon, label, badge, panel) in enumerate(tabs):
859 checked = " checked" if idx == 0 else ""
860 inputs.append(
861 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
862 )
863 # Telemetry is cross-cutting, not a loop — float it to the far right.
864 lcls = ' class="aside"' if tid == "tab-tel" else ""
865 labels.append(
866 f'<label{lcls} for="{tid}">{_icon(icon)}{escape(label)}'
867 f'<span class="badge">{escape(badge)}</span></label>'
868 )
869 panels.append(panel)
870 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
871 rules.append(
872 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
873 "{color:var(--fg);border-bottom-color:var(--accent)}"
874 )
875 tabcss = "".join(rules)
876 return (
877 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
878 '<meta name="viewport" content="width=device-width,initial-scale=1">'
879 "<title>froot &middot; read-model</title>"
880 f"<style>{_CSS}{tabcss}</style>"
881 f"<script>{_THEME_JS}</script></head><body>"
882 + "".join(inputs)
883 + "<main>"
884 + _header(model)
885 + f'<nav class="tabbar">{"".join(labels)}</nav>'
886 + "".join(panels)
887 + _footer()
888 + "</main></body></html>"
889 )
890 
891 
892def _footer() -> str:
893 return (
894 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
895 "auto-merges only on an allowlisted repo where a class has earned its "
896 "gate (the allowlist is empty by default, so commit authority is none "
897 "until a steward opts in). Trust is earned, narrow to one loop on one "
898 "repo, conditional on its environment "
899 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
900 "Everything here is derived per request from GitHub + Temporal + "
901 "ClickHouse; froot keeps no database.</footer>"
902 )

page() loops model.advisory to append one tab per view — no hard-coded determinism+a11y pair. A new advisory loop's tab appears here with no renderer edit.

src/froot/dashboard/render.py · 902 lines
src/froot/dashboard/render.py902 lines · Python
⋯ 833 lines hidden (lines 1–833)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript (tabs are CSS-only, via hidden radio
4inputs), and the page makes no network request of its own — it is a static,
5full-screen projection of an already-computed
6:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
7HTML-escaped at the boundary.
8 
9The shape is gate-first: each loop is a tab whose hero is the *gate* — a small
10flow of the four trust bearings into the earned/hold decision — over a compact
11metric grid and foldable detail. Each loop is a distinct trust class (§3.9), so
12each tab is the whole dashboard scoped to one loop; determinism-review and the
13cross-cutting run-telemetry get their own tabs.
14"""
15 
16from __future__ import annotations
17 
18from datetime import UTC, datetime, timedelta
19from html import escape
20from typing import TYPE_CHECKING
21 
22if TYPE_CHECKING:
23 from froot.dashboard.model import (
24 AdvisoryRow,
25 AdvisoryView,
26 BumpRow,
27 ClassGate,
28 DashboardModel,
29 LoopView,
30 RunTelemetry,
31 )
32 
33_LIGHT = (
34 "--fg:#1b1f24;--mut:#586273;--faint:#8b94a3;--line:#e9ebef;--hair:#d6dae1;"
35 "--bg:#fcfcfd;--tint:#f5f6f9;--accent:#7c3aed;--ok:#2f7d4f;--warn:#8a6011;"
36 "--bad:#b23a2e"
38_DARK = (
39 "--fg:#e7e9ee;--mut:#9aa3b2;--faint:#6b7484;--line:#22262e;--hair:#333a45;"
40 "--bg:#0b0d10;--tint:#14171c;--accent:#a78bfa;--ok:#4cae6e;--warn:#d6a23f;"
41 "--bad:#e07a6e"
43# Editorial-minimal "read-model" idiom: structure from hairlines + whitespace +
44# type, never from box fills; semantic color rides on dots and the value text,
45# not on filled chips. Light is the default; a toggle (data-theme) forces either
46# theme, and bare system preference is honored when nothing is forced.
47_CSS = f"""
48:root{{{_LIGHT};
49--serif:"Iowan Old Style",Palatino,Georgia,"Times New Roman",ui-serif,serif;
50--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
51@media(prefers-color-scheme:dark){{:root:not([data-theme]){{{_DARK}}}}}
52:root[data-theme=dark]{{{_DARK}}}
53*{{box-sizing:border-box}}
54body{{margin:0;background:var(--bg);color:var(--fg);
55font:14px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
56-webkit-font-smoothing:antialiased}}
57main{{padding:0 clamp(16px,3vw,46px) 72px}}
58a{{color:var(--accent);text-decoration:none}}a:hover{{text-decoration:underline}}
59.mono{{font-family:var(--mono);font-size:.92em}}
60.serif{{font-family:var(--serif)}}
61.mut{{color:var(--mut)}}.ok{{color:var(--ok)}}.warn{{color:var(--warn)}}
62.bad{{color:var(--bad)}}.acc{{color:var(--accent)}}
63.num{{font-variant-numeric:tabular-nums}}
64/* status dot — the one carrier of valence besides the value text itself */
65.dot{{display:inline-block;width:8px;height:8px;border-radius:50%;flex:none}}
66.dot.ok{{background:var(--ok)}}.dot.warn{{background:var(--warn)}}
67.dot.bad{{background:var(--bad)}}.dot.mute{{background:var(--faint)}}
68.dot.acc{{background:var(--accent)}}
69/* header */
70header{{display:flex;align-items:center;flex-wrap:wrap;gap:9px 22px;
71padding:26px 0 18px;border-bottom:1px solid var(--line)}}
72h1{{font-family:var(--serif);font-size:23px;margin:0;letter-spacing:-.01em;
73font-weight:600;display:inline-flex;align-items:center;gap:9px}}
74.ic{{width:15px;height:15px;flex:none}}
75.mark{{width:18px;height:18px;flex:none;color:var(--accent)}}
76.hstatus{{display:flex;align-items:center;gap:7px;font-size:13px}}
77.hmeta{{margin-left:auto;display:flex;flex-wrap:wrap;align-items:center;
78gap:7px 16px;color:var(--mut);font-size:12px}}
79.srcs{{display:inline-flex;flex-wrap:wrap;align-items:center;gap:5px 14px}}
80.src{{display:inline-flex;align-items:center;gap:6px}}
81.themetgl{{background:transparent;border:1px solid var(--line);border-radius:
82999px;width:30px;height:30px;cursor:pointer;color:var(--mut);font-size:14px;
83line-height:1;display:inline-flex;align-items:center;justify-content:center;
84transition:border-color .14s,color .14s}}
85.themetgl:hover{{border-color:var(--fg);color:var(--fg)}}
86/* tabs (CSS-only radios) */
87.tabin{{position:absolute;opacity:0;pointer-events:none}}
88nav.tabbar{{display:flex;flex-wrap:wrap;gap:2px 6px;margin:16px 0 0;
89border-bottom:1px solid var(--line)}}
90nav.tabbar label{{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
91padding:10px 4px;margin:0 12px -1px 0;font-size:13.5px;font-weight:600;
92color:var(--mut);border-bottom:2px solid transparent;white-space:nowrap;
93transition:color .14s}}
94nav.tabbar label:hover{{color:var(--fg)}}
95nav.tabbar label.aside{{margin-left:auto}}
96nav.tabbar label .badge{{font-family:var(--mono);font-weight:500;font-size:11px;
97color:var(--faint)}}
98.panel{{display:none;padding:26px 0 0;animation:fade .16s ease}}
99@keyframes fade{{from{{opacity:.45}}to{{opacity:1}}}}
100/* gate hero */
101.hero{{display:grid;align-items:start;margin:0 0 4px;gap:22px 40px;
102grid-template-columns:minmax(330px,1.05fr) minmax(300px,1fr)}}
103@media(max-width:780px){{.hero{{grid-template-columns:1fr}}}}
104.heroh{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
105color:var(--mut);font-weight:600;margin:0 0 14px;padding-bottom:7px;
106border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}}
107.gateflow{{display:flex;align-items:center;gap:14px;flex-wrap:wrap}}
108.bearings{{display:flex;flex-direction:column;min-width:172px;flex:1 1 172px}}
109.bearing{{display:flex;align-items:center;justify-content:space-between;gap:12px;
110padding:7px 0;border-bottom:1px solid var(--line);font-size:12.5px}}
111.bearing:last-child{{border-bottom:0}}
112.bearing .bl{{color:var(--mut)}}
113.bearing .bv{{display:inline-flex;align-items:center;gap:7px;font-weight:600;
114font-variant-numeric:tabular-nums}}
115.bearing.armed .bv{{color:var(--faint);font-weight:500}}
116.flowarrow{{color:var(--hair);font-size:17px;line-height:1}}
117.gatenode{{flex:0 0 auto;text-align:center;padding:2px 6px}}
118.gatenode .gl{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
119color:var(--mut);font-weight:600}}
120.gatenode .gv{{font-family:var(--serif);font-size:30px;font-weight:600;
121line-height:1.1;color:var(--fg);font-variant-numeric:tabular-nums}}
122.gatenode .gs{{font-size:11px;color:var(--faint)}}
123.outcome{{flex:0 0 auto;display:flex;flex-direction:column;gap:3px}}
124.outcome .ot{{display:flex;align-items:center;gap:7px;font-size:15px;
125font-weight:700;letter-spacing:.02em}}
126.outcome .os{{font-size:11px;color:var(--faint)}}
127.outcome.act .ot{{color:var(--ok)}}
128.outcome.hold .ot{{color:var(--mut)}}
129.caption{{color:var(--mut);font-size:12px;margin:14px 0 0;line-height:1.5;
130max-width:62ch}}
131/* per-class table inside the hero */
132.classes{{width:100%;border-collapse:collapse;font-size:12.5px}}
133.classes th,.classes td{{text-align:left;padding:7px 12px 7px 0;
134border-bottom:1px solid var(--line);vertical-align:baseline}}
135.classes th{{color:var(--faint);font-weight:600;font-size:10.5px;
136text-transform:uppercase;letter-spacing:.06em}}
137.classes td.r{{font-variant-numeric:tabular-nums}}
138.pill{{display:inline-flex;align-items:center;gap:6px;font-size:12px;
139font-weight:600}}
140.pill.ok{{color:var(--ok)}}.pill.hold{{color:var(--mut);font-weight:500}}
141/* metric strip — bare stats, no boxes; a hairline frames the row */
142.cards{{display:grid;gap:20px 30px;margin:26px 0 0;padding:18px 0 0;
143border-top:1px solid var(--line);
144grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
145.card .n{{font-size:26px;font-weight:600;line-height:1.05;
146font-variant-numeric:tabular-nums;letter-spacing:-.01em}}
147.card .l{{color:var(--mut);font-size:11px;text-transform:uppercase;
148letter-spacing:.05em;font-weight:600;margin-top:5px}}
149.card .x{{color:var(--faint);font-size:11.5px;margin-top:3px}}
150/* section + detail */
151.sec{{margin:30px 0 0}}
152.sech{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
153color:var(--mut);font-weight:600;margin:0 0 12px;padding-bottom:7px;
154border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}}
155.sech .n{{color:var(--faint);font-weight:500;letter-spacing:0;
156text-transform:none}}
157details.fold{{margin:14px 0 0;border-top:1px solid var(--line)}}
158details.fold>summary{{cursor:pointer;list-style:none;padding:11px 0;
159font-size:12px;font-weight:600;color:var(--mut);display:flex;
160align-items:center;gap:9px}}
161details.fold>summary:hover{{color:var(--fg)}}
162details.fold>summary::-webkit-details-marker{{display:none}}
163details.fold>summary::before{{content:"\\25B8";color:var(--hair);font-size:10px}}
164details.fold[open]>summary::before{{content:"\\25BE"}}
165details.fold>summary .c{{color:var(--faint);font-weight:500}}
166details.fold .body{{padding:2px 0 16px}}
167table.data{{width:100%;border-collapse:collapse;font-size:12.5px}}
168table.data th,table.data td{{text-align:left;padding:7px 14px 7px 0;
169border-bottom:1px solid var(--line);vertical-align:baseline}}
170table.data th{{color:var(--faint);font-weight:600;font-size:10.5px;
171text-transform:uppercase;letter-spacing:.05em}}
172table.data td.r{{font-variant-numeric:tabular-nums;white-space:nowrap}}
173.empty{{color:var(--faint);font-size:13px;padding:16px 0}}
174.t{{font-size:12px;color:var(--mut)}}
175.t.ok{{color:var(--ok)}}.t.warn{{color:var(--warn)}}.t.bad{{color:var(--bad)}}
176.cad{{color:var(--mut);font-size:12px;margin:18px 0 0}}
177.cad b{{color:var(--fg);font-weight:600}}
178footer{{margin:46px 0 0;padding-top:18px;border-top:1px solid var(--line);
179color:var(--mut);font-size:12px;line-height:1.7;max-width:92ch}}
180footer b{{color:var(--fg);font-weight:600}}
181"""
182 
183# The page's only script: set the saved theme before first paint (no flash) and
184# expose the toggle. Inline + tiny, so the page stays one self-contained file
185# with no external request; absent JS, the page simply follows the OS theme.
186_THEME_JS = (
187 "(function(){var k='froot-theme',r=document.documentElement,"
188 "s=localStorage.getItem(k);if(s)r.setAttribute('data-theme',s);"
189 "window.__toggleTheme=function(){var c=r.getAttribute('data-theme')||"
190 "(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');"
191 "var n=c==='dark'?'light':'dark';r.setAttribute('data-theme',n);"
192 "localStorage.setItem(k,n);};})();"
194 
195# A small set of hairline line-icons (inline SVG, stroke = currentColor, so they
196# inherit the muted ink and theme themselves). Anchors for the eye — tabs,
197# section headers, the wordmark — never on the number strip. Paths are 24x24.
198_ICONS = {
199 "package": (
200 '<path d="M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 '
201 '16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z"/>'
202 '<path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'
203 ),
204 "shield": (
205 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
206 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
207 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>'
208 ),
209 "shield-check": (
210 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
211 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
212 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>'
213 ),
214 "search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
215 "activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
216 "layers": (
217 '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 '
218 '3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>'
219 '<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/>'
220 '<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
221 ),
222 "inbox": (
223 '<path d="M22 12h-6l-2 3h-4l-2-3H2"/>'
224 '<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45'
225 '-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>'
226 ),
227 "lock": (
228 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
229 '<path d="M7 11V7a5 5 0 0 1 10 0v4"/>'
230 ),
231 "lock-open": (
232 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
233 '<path d="M7 11V7a5 5 0 0 1 9.9-1"/>'
234 ),
235 "merge": (
236 '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/>'
237 '<path d="M6 21V9a9 9 0 0 0 9 9"/>'
238 ),
239 "scissors": (
240 '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/>'
241 '<path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/>'
242 '<path d="M14.8 14.8 20 20"/>'
243 ),
244 "accessibility": (
245 '<circle cx="16" cy="4" r="1"/><path d="m18 19 1-7-6 1"/>'
246 '<path d="m5 8 3-3 5.5 3-2.36 3.5"/>'
247 '<path d="M4.24 14.5a5 5 0 0 0 6.88 6"/>'
248 '<path d="M13.76 17.5a5 5 0 0 0-6.88-6"/>'
249 ),
251 
252 
253def _icon(name: str, cls: str = "ic") -> str:
254 return (
255 f'<svg class="{cls}" viewBox="0 0 24 24" fill="none" '
256 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
257 f'stroke-linejoin="round" aria-hidden="true">{_ICONS[name]}</svg>'
258 )
259 
260 
261def _wordmark() -> str:
262 """The froot mark: a fruit in the accent, with a single green leaf.
263 
264 Two-tone on purpose — the one spot color is allowed to be richer. The body
265 and stem ride the accent (currentColor); the leaf is the semantic green,
266 so the mark reads as a piece of fruit, not a logo abstraction.
267 """
268 return (
269 '<svg class="mark" viewBox="0 0 24 24" fill="none" '
270 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
271 'stroke-linejoin="round" aria-hidden="true">'
272 '<circle cx="11.5" cy="14.5" r="6.2"/>'
273 '<path d="M11.5 8.3V6.15" stroke="var(--ok)"/>'
274 '<path d="M11.5 6.15c1.4-2 3.5-2.6 5.8-2.2-.4 2.5-2.5 3.7-5.8 2.2z" '
275 'stroke="var(--ok)"/></svg>'
276 )
277 
278 
279_CI_CLASS = {
280 "passed": "ok",
281 "failed": "bad",
282 "absent": "mut",
283 "timed_out": "warn",
285_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
286_POST_MERGE_CLASS = {
287 "held": "ok",
288 "broke": "bad",
289 "reverted": "bad",
290 "unknown": "mut",
292_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
293_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
294 
295 
296# ── small formatters ─────────────────────────────────────────────────────────
297def _aware(when: datetime) -> datetime:
298 return when if when.tzinfo else when.replace(tzinfo=UTC)
299 
300 
301def _ago(when: datetime | None, now: datetime) -> str:
302 if when is None:
303 return ""
304 secs = (now - _aware(when)).total_seconds()
305 if secs < 90:
306 return "just now"
307 mins = secs / 60
308 if mins < 90:
309 return f"{round(mins)}m ago"
310 hours = mins / 60
311 if hours < 36:
312 return f"{round(hours)}h ago"
313 return f"{round(hours / 24)}d ago"
314 
315 
316def _until(when: datetime | None, now: datetime) -> str:
317 if when is None:
318 return ""
319 secs = (_aware(when) - now).total_seconds()
320 if secs <= 0:
321 return "due"
322 mins = secs / 60
323 if mins < 90:
324 return f"~{round(mins)}m"
325 hours = mins / 60
326 if hours < 36:
327 return f"~{round(hours)}h"
328 return f"~{round(hours / 24)}d"
329 
330 
331def _pct(rate: float | None) -> str:
332 return "" if rate is None else f"{rate * 100:.0f}%"
333 
334 
335def _dot(kind: str) -> str:
336 return f'<span class="dot {kind}"></span>'
337 
338 
339def _tag(value: str | None, classes: dict[str, str]) -> str:
340 if value is None:
341 return '<span class="t">—</span>'
342 cls = classes.get(value, "")
343 return f'<span class="t {cls}">{escape(value)}</span>'
344 
345 
346def _pr_link(row: BumpRow) -> str:
347 if row.pr_url is None or row.pr_number is None:
348 return '<span class="mut">—</span>'
349 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
350 
351 
352def _short_id(workflow_id: str) -> str:
353 return escape(workflow_id.removeprefix("froot-bump-"))
354 
355 
356# ── header ───────────────────────────────────────────────────────────────────
357def _alive(model: DashboardModel) -> tuple[str, str]:
358 """Global liveness dot + label across every loop."""
359 advisory_loops = [loop for v in model.advisory for loop in v.loops]
360 live = sum(1 for x in model.scan_loops if x.live) + sum(
361 1 for x in advisory_loops if x.live
362 )
363 total = len(model.scan_loops) + len(advisory_loops)
364 if total == 0:
365 return "mute", "no loops configured"
366 kind = "ok" if live == total else ("warn" if live else "bad")
367 return kind, f"{live}/{total} loops live"
368 
369 
370def _header(model: DashboardModel) -> str:
371 kind, label = _alive(model)
372 sources = "".join(
373 f'<span class="src">{_dot("ok" if s.ok else "bad")}'
374 f"{escape(s.name)}</span>"
375 for s in model.sources
376 )
377 return (
378 "<header>"
379 f"<h1>{_wordmark()}froot</h1>"
380 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
381 f'<span class="hmeta"><span class="srcs">{sources}</span>'
382 '<button class="themetgl" type="button" onclick="__toggleTheme()"'
383 ' title="Toggle light / dark"'
384 ' aria-label="Toggle light or dark theme">&#9680;</button>'
385 "</span></header>"
386 )
387 
388 
389# ── the gate hero (per loop) ─────────────────────────────────────────────────
390def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
391 cls = "bearing armed" if armed else "bearing"
392 dot = _dot("mute" if armed else kind)
393 return (
394 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
395 f'<span class="bv {kind}">{dot}{escape(value)}</span></div>'
396 )
397 
398 
399def _gate_hero(view: LoopView) -> str:
400 t, rel, pr = view.track_record, view.reliability, view.probes
401 earned = sum(1 for g in view.class_gates if g.earned)
402 total = len(view.class_gates)
403 acting = any(r.would_auto_merge for r in view.gate)
404 # The four bearings: rate + defect come from the record; the adversarial
405 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
406 # the merge, so it is shown armed (always-on), not a record figure.
407 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
408 defect_ok = "ok" if not rel.defect_rate else "bad"
409 probe_ok = "ok" if pr.escaped == 0 else "bad"
410 bearings = "".join(
411 (
412 _bearing(
413 "approval rate",
414 _pct(t.merge_rate),
415 rate_ok if t.merge_rate is not None else "mut",
416 ),
417 _bearing(
418 "defect rate",
419 _pct(rel.defect_rate),
420 defect_ok if rel.defect_rate is not None else "mut",
421 ),
422 _bearing(
423 "probe",
424 f"{pr.escaped} escaped" if pr.total else "none",
425 probe_ok if pr.total else "mut",
426 ),
427 _bearing("deep review", "armed", "mut", armed=True),
428 )
429 )
430 if total == 0:
431 node = (
432 '<div class="gatenode"><div class="gl">gate</div>'
433 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
434 )
435 else:
436 node = (
437 f'<div class="gatenode"><div class="gl">earned</div>'
438 f'<div class="gv">{earned}/{total}</div>'
439 '<div class="gs">classes</div></div>'
440 )
441 if acting:
442 out = (
443 f'<div class="outcome act"><div class="ot">{_icon("merge")}'
444 "AUTO-MERGE</div>"
445 '<div class="os">a class is acting now</div></div>'
446 )
447 elif earned:
448 out = (
449 f'<div class="outcome act"><div class="ot">{_icon("lock-open")}'
450 "EARNED</div>"
451 '<div class="os">acts where allowlisted</div></div>'
452 )
453 else:
454 out = (
455 f'<div class="outcome hold"><div class="ot">{_icon("lock")}HOLD'
456 "</div><div class="
457 '"os">building the record</div></div>'
458 )
459 flow = (
460 '<div class="gateflow">'
461 f'<div class="bearings">{bearings}</div>'
462 '<div class="flowarrow">&rarr;</div>'
463 f"{node}"
464 '<div class="flowarrow">&rarr;</div>'
465 f"{out}</div>"
466 )
467 caption = (
468 '<p class="caption">A class earns the gate by triangulation: a high '
469 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
470 "evidence. Two further legs guard the live merge — an adversarial "
471 "<b>probe</b> and an independent <b>deep review</b> at merge. "
472 "Auto-merge is allowlist-gated (off by default).</p>"
473 )
474 return (
475 f'<div><div class="heroh">{_icon("shield-check")}'
476 "Earned autonomy &middot; the gate</div>"
477 f"{flow}{caption}</div>"
478 )
479 
480 
481def _class_table(view: LoopView) -> str:
482 if not view.class_gates:
483 return (
484 f'<div><div class="heroh">{_icon("layers")}Classes</div>'
485 '<div class="empty">No classes yet &mdash; a (repo, loop) '
486 "earns the gate from its own track record.</div></div>"
487 )
488 rows = "".join(_class_row(g) for g in view.class_gates)
489 return (
490 f'<div><div class="heroh">{_icon("layers")}Per-class standing</div>'
491 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
492 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
493 f"<tbody>{rows}</tbody></table></div>"
494 )
495 
496 
497def _class_row(g: ClassGate) -> str:
498 if g.earned:
499 gate = f'<span class="pill ok">{_dot("ok")}earned</span>'
500 else:
501 gate = (
502 f'<span class="pill hold">{_dot("mute")}hold</span> '
503 f'<span class="mut">{escape(g.blocker or "")}</span>'
504 )
505 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
506 budget = (
507 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
508 if g.approvals_per_week
509 else ""
510 )
511 return (
512 f'<tr><td class="mono">{escape(g.repo)}</td>'
513 f'<td class="r">{_pct(g.merge_rate)}</td>'
514 f'<td class="r">{escape(defect)}</td>'
515 f"<td>{gate}</td>"
516 f'<td class="r mut">{escape(budget)}</td></tr>'
517 )
518 
519 
520# ── metric cards (per loop) ──────────────────────────────────────────────────
521def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
522 x = f'<div class="x">{extra}</div>' if extra else ""
523 return (
524 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
525 f'<div class="l">{escape(label)}</div>{x}</div>'
526 )
527 
528 
529def _loop_cards(view: LoopView) -> str:
530 t, v, rel, j, pr = (
531 view.track_record,
532 view.verification,
533 view.reliability,
534 view.judgment,
535 view.probes,
536 )
537 defects = rel.broke + rel.reverted
538 cards = [
539 _card(
540 t.opened,
541 "proposed",
542 f"{t.merged} merged · {t.closed_unmerged} closed",
543 ),
544 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
545 _card(
546 t.open_now,
547 "awaiting you",
548 "open, needs a human",
549 "warn" if t.open_now else "",
550 ),
551 _card(
552 _pct(rel.defect_rate) if rel.determined else "",
553 "defect rate",
554 f"{rel.held} held · {defects} broke",
555 "bad" if defects else "",
556 ),
557 _card(
558 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
559 "CI passed",
560 "the oracle",
561 ),
562 _card(
563 pr.escaped if pr.total else "0",
564 "probes escaped",
565 f"{pr.caught} caught" if pr.total else "no probes yet",
566 "bad" if pr.escaped else "",
567 ),
568 _card(
569 j.clean,
570 "clean verdicts",
571 f"{j.clean_but_failed} mis-judged"
572 if j.clean_but_failed
573 else "judge calibrated",
574 ),
575 _card(rel.held, "merges held", "post-merge, stayed green"),
576 ]
577 return f'<div class="cards">{"".join(cards)}</div>'
578 
579 
580# ── detail tables ────────────────────────────────────────────────────────────
581def _cadence(view: LoopView, now: datetime) -> str:
582 live = sum(1 for s in view.scan_loops if s.live)
583 total = len(view.scan_loops)
584 nxt = ""
585 last = max(
586 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
587 default=None,
588 )
589 if last is not None:
590 due = last + timedelta(seconds=view.scan_interval_seconds)
591 nxt = f" &middot; next {escape(_until(due, now))}"
592 every = round(view.scan_interval_seconds / 3600, 1)
593 return (
594 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
595 f"&middot; every <b>{every}h</b>{nxt}</p>"
596 )
597 
598 
599def _bumps_fold(view: LoopView) -> str:
600 if not view.bumps:
601 return ""
602 rows = "".join(
603 "<tr>"
604 f'<td class="mono">{escape(r.package)}</td>'
605 f'<td class="mono mut">{escape(r.to_version)}</td>'
606 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
607 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
608 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
609 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
610 f"<td>{_pr_link(r)}</td>"
611 "</tr>"
612 for r in view.bumps
613 )
614 return (
615 '<details class="fold"><summary>Bumps '
616 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
617 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
618 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
619 "<th>pr</th></tr></thead>"
620 f"<tbody>{rows}</tbody></table></div></details>"
621 )
622 
623 
624def _failures_fold(view: LoopView) -> str:
625 if not view.failures:
626 return ""
627 rows = "".join(
628 "<tr>"
629 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
630 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
631 f'<td class="mut">{escape(f.reason or "")}</td>'
632 "</tr>"
633 for f in view.failures
634 )
635 return (
636 '<details class="fold"><summary class="bad">Failures '
637 f'<span class="c">{len(view.failures)}</span></summary>'
638 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
639 "<th>kind</th><th>reason</th></tr></thead>"
640 f"<tbody>{rows}</tbody></table></div></details>"
641 )
642 
643 
644# ── panels ───────────────────────────────────────────────────────────────────
645def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
646 queue = _queue_sec(view)
647 return (
648 f'<section class="panel" id="{pid}">'
649 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
650 f"{_loop_cards(view)}"
651 f"{queue}"
652 f"{_cadence(view, now)}"
653 f"{_bumps_fold(view)}{_failures_fold(view)}"
654 "</section>"
655 )
656 
657 
658def _queue_sec(view: LoopView) -> str:
659 if not view.gate:
660 return (
661 f'<div class="sec"><div class="sech">{_icon("inbox")}'
662 'Approval queue <span class="n">empty</span></div>'
663 '<div class="empty">Nothing awaiting you.</div></div>'
664 )
665 rows = "".join(
666 "<tr>"
667 f'<td class="mono">{escape(r.package)}</td>'
668 f'<td class="mono mut">{escape(r.to_version)}</td>'
669 f"<td>{_queue_badge(r)}</td>"
670 f"<td>{_pr_link(r)}</td>"
671 "</tr>"
672 for r in view.gate
673 )
674 return (
675 f'<div class="sec"><div class="sech">{_icon("inbox")}Approval queue '
676 f'<span class="n">{len(view.gate)} yours</span></div>'
677 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
678 "<th>gate</th><th>pr</th></tr></thead>"
679 f"<tbody>{rows}</tbody></table></div>"
680 )
681 
682 
683def _queue_badge(row: BumpRow) -> str:
684 if row.would_auto_merge:
685 return f'<span class="pill ok">{_dot("ok")}would auto-merge</span>'
686 reason = row.held_reason or "held"
687 return f'<span class="mut">held &middot; {escape(reason)}</span>'
688 
689 
690def _advisory_panel(view: AdvisoryView, pid: str) -> str:
691 """One advisory loop's tab, presentation (icon, title) from its spec."""
692 r = view.record
693 live = sum(1 for x in view.loops if x.live)
694 flag = "bad" if r.findings else ""
695 cards = (
696 '<div class="cards">'
697 f"{_card(r.repos_covered, 'repos covered')}"
698 f"{_card(f'{live}/{len(view.loops)}', 'loops live')}"
699 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
700 f"{_card(r.findings, 'findings', 'advisory', flag)}"
701 "</div>"
702 )
703 body = (
704 '<div class="empty">No PRs reviewed yet.</div>'
705 if not view.rows
706 else _advisory_table(view.rows)
707 )
708 note = (
709 '<p class="caption">An advisory loop. froot re-derives each open '
710 "PR's findings every tick and upserts one decaying comment, never "
711 "blocking a merge.</p>"
712 )
713 every = round(view.interval_seconds / 60, 1)
714 cad = (
715 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
716 f"every <b>{every}m</b></p>"
717 )
718 return (
719 f'<section class="panel" id="{pid}">'
720 f'<div class="heroh">{_icon(view.icon)}{escape(view.title)}</div>'
721 f"{note}{cards}{cad}"
722 f'<div class="sec"><div class="sech">Reviews '
723 f'<span class="n">{len(view.rows)}</span></div>{body}</div>'
724 "</section>"
725 )
726 
727 
728def _advisory_table(rows: tuple[AdvisoryRow, ...]) -> str:
729 body = "".join(
730 "<tr>"
731 f'<td class="mono">{escape(row.repo)}</td>'
732 f"<td>{_advisory_pr_link(row)}</td>"
733 f"<td>{_advisory_findings_cell(row)}</td>"
734 f'<td class="mono mut">{escape(", ".join(row.detail) or "")}</td>'
735 "</tr>"
736 for row in rows
737 )
738 return (
739 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
740 "<th>findings</th><th>details</th></tr></thead>"
741 f"<tbody>{body}</tbody></table>"
742 )
743 
744 
745def _advisory_pr_link(row: AdvisoryRow) -> str:
746 if row.pr_url is None or row.pr_number is None:
747 return '<span class="mut">—</span>'
748 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
749 
750 
751def _advisory_findings_cell(row: AdvisoryRow) -> str:
752 if row.findings == 0:
753 return '<span class="ok">clean</span>'
754 label = "finding" if row.findings == 1 else "findings"
755 link = ""
756 if row.comment_url:
757 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
758 return f'<span class="bad">{row.findings} {label}</span>{link}'
759 
760 
761def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
762 tel: RunTelemetry = model.telemetry
763 if not tel.available:
764 body = (
765 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
766 "froot traces in the window.</div>"
767 )
768 else:
769 rows = "".join(
770 "<tr>"
771 f'<td class="mono">{escape(a.name)}</td>'
772 f'<td class="r">{a.count}</td>'
773 f'<td class="r">{a.avg_ms:.0f} ms</td>'
774 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
775 "</tr>"
776 for a in tel.activities
777 )
778 last = f"last {_ago(tel.last_activity, now)}"
779 err = "bad" if tel.error_spans else ""
780 head = (
781 '<div class="cards">'
782 f"{_card(tel.total_spans, 'spans', last)}"
783 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
784 f"{_card(f'{tel.window_days}d', 'window')}</div>"
785 )
786 table = (
787 '<div class="sec"><div class="sech">Activity latency</div>'
788 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
789 "<th>avg</th><th>max</th></tr></thead>"
790 f"<tbody>{rows}</tbody></table></div>"
791 )
792 body = head + table
793 note = (
794 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
795 "(trace-derived, best-effort) — latency per activity across "
796 "every loop.</p>"
797 )
798 return (
799 f'<section class="panel" id="{pid}">'
800 f'<div class="heroh">{_icon("activity")}Run telemetry '
801 "&middot; ClickHouse</div>"
802 f"{note}{body}</section>"
803 )
804 
805 
806# ── tabs + page ──────────────────────────────────────────────────────────────
807def _loop_badge(view: LoopView) -> str:
808 earned = sum(1 for g in view.class_gates if g.earned)
809 if view.class_gates and earned:
810 return f"{earned}/{len(view.class_gates)} earned"
811 if view.track_record.open_now:
812 return f"{view.track_record.open_now} open"
813 return str(view.track_record.opened)
814 
815 
816def page(model: DashboardModel) -> str:
817 """Render the whole dashboard as one self-contained HTML document."""
818 now = model.generated_at
819 # (tab-id, panel-id, icon, label, badge, panel-html)
820 tabs: list[tuple[str, str, str, str, str, str]] = []
821 for i, view in enumerate(model.bump_loops):
822 pid, tid = f"panel-{i}", f"tab-{i}"
823 icon = view.icon
824 tabs.append(
825 (
826 tid,
827 pid,
828 icon,
829 view.title,
830 _loop_badge(view),
831 _loop_panel(view, pid, now),
832 )
833 )
834 for j, advisory in enumerate(model.advisory):
835 pid, tid = f"panel-adv-{j}", f"tab-adv-{j}"
836 tabs.append(
837 (
838 tid,
839 pid,
840 advisory.icon,
841 advisory.title,
842 str(advisory.record.reviewed),
843 _advisory_panel(advisory, pid),
844 )
845 )
⋯ 57 lines hidden (lines 846–902)
846 tabs.append(
847 (
848 "tab-tel",
849 "panel-tel",
850 "activity",
851 "Telemetry",
852 "live" if model.telemetry.available else "off",
853 _telemetry_panel(model, "panel-tel", now),
854 )
855 )
856 
857 inputs, labels, panels, rules = [], [], [], []
858 for idx, (tid, pid, icon, label, badge, panel) in enumerate(tabs):
859 checked = " checked" if idx == 0 else ""
860 inputs.append(
861 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
862 )
863 # Telemetry is cross-cutting, not a loop — float it to the far right.
864 lcls = ' class="aside"' if tid == "tab-tel" else ""
865 labels.append(
866 f'<label{lcls} for="{tid}">{_icon(icon)}{escape(label)}'
867 f'<span class="badge">{escape(badge)}</span></label>'
868 )
869 panels.append(panel)
870 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
871 rules.append(
872 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
873 "{color:var(--fg);border-bottom-color:var(--accent)}"
874 )
875 tabcss = "".join(rules)
876 return (
877 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
878 '<meta name="viewport" content="width=device-width,initial-scale=1">'
879 "<title>froot &middot; read-model</title>"
880 f"<style>{_CSS}{tabcss}</style>"
881 f"<script>{_THEME_JS}</script></head><body>"
882 + "".join(inputs)
883 + "<main>"
884 + _header(model)
885 + f'<nav class="tabbar">{"".join(labels)}</nav>'
886 + "".join(panels)
887 + _footer()
888 + "</main></body></html>"
889 )
890 
891 
892def _footer() -> str:
893 return (
894 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
895 "auto-merges only on an allowlisted repo where a class has earned its "
896 "gate (the allowlist is empty by default, so commit authority is none "
897 "until a steward opts in). Trust is earned, narrow to one loop on one "
898 "repo, conditional on its environment "
899 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
900 "Everything here is derived per request from GitHub + Temporal + "
901 "ClickHouse; froot keeps no database.</footer>"
902 )

One behaviour change worth flagging: the global header's live/total loops live aggregate now spans every advisory view's loops.

src/froot/dashboard/render.py · 902 lines
src/froot/dashboard/render.py902 lines · Python
⋯ 356 lines hidden (lines 1–356)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript (tabs are CSS-only, via hidden radio
4inputs), and the page makes no network request of its own — it is a static,
5full-screen projection of an already-computed
6:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
7HTML-escaped at the boundary.
8 
9The shape is gate-first: each loop is a tab whose hero is the *gate* — a small
10flow of the four trust bearings into the earned/hold decision — over a compact
11metric grid and foldable detail. Each loop is a distinct trust class (§3.9), so
12each tab is the whole dashboard scoped to one loop; determinism-review and the
13cross-cutting run-telemetry get their own tabs.
14"""
15 
16from __future__ import annotations
17 
18from datetime import UTC, datetime, timedelta
19from html import escape
20from typing import TYPE_CHECKING
21 
22if TYPE_CHECKING:
23 from froot.dashboard.model import (
24 AdvisoryRow,
25 AdvisoryView,
26 BumpRow,
27 ClassGate,
28 DashboardModel,
29 LoopView,
30 RunTelemetry,
31 )
32 
33_LIGHT = (
34 "--fg:#1b1f24;--mut:#586273;--faint:#8b94a3;--line:#e9ebef;--hair:#d6dae1;"
35 "--bg:#fcfcfd;--tint:#f5f6f9;--accent:#7c3aed;--ok:#2f7d4f;--warn:#8a6011;"
36 "--bad:#b23a2e"
38_DARK = (
39 "--fg:#e7e9ee;--mut:#9aa3b2;--faint:#6b7484;--line:#22262e;--hair:#333a45;"
40 "--bg:#0b0d10;--tint:#14171c;--accent:#a78bfa;--ok:#4cae6e;--warn:#d6a23f;"
41 "--bad:#e07a6e"
43# Editorial-minimal "read-model" idiom: structure from hairlines + whitespace +
44# type, never from box fills; semantic color rides on dots and the value text,
45# not on filled chips. Light is the default; a toggle (data-theme) forces either
46# theme, and bare system preference is honored when nothing is forced.
47_CSS = f"""
48:root{{{_LIGHT};
49--serif:"Iowan Old Style",Palatino,Georgia,"Times New Roman",ui-serif,serif;
50--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
51@media(prefers-color-scheme:dark){{:root:not([data-theme]){{{_DARK}}}}}
52:root[data-theme=dark]{{{_DARK}}}
53*{{box-sizing:border-box}}
54body{{margin:0;background:var(--bg);color:var(--fg);
55font:14px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
56-webkit-font-smoothing:antialiased}}
57main{{padding:0 clamp(16px,3vw,46px) 72px}}
58a{{color:var(--accent);text-decoration:none}}a:hover{{text-decoration:underline}}
59.mono{{font-family:var(--mono);font-size:.92em}}
60.serif{{font-family:var(--serif)}}
61.mut{{color:var(--mut)}}.ok{{color:var(--ok)}}.warn{{color:var(--warn)}}
62.bad{{color:var(--bad)}}.acc{{color:var(--accent)}}
63.num{{font-variant-numeric:tabular-nums}}
64/* status dot — the one carrier of valence besides the value text itself */
65.dot{{display:inline-block;width:8px;height:8px;border-radius:50%;flex:none}}
66.dot.ok{{background:var(--ok)}}.dot.warn{{background:var(--warn)}}
67.dot.bad{{background:var(--bad)}}.dot.mute{{background:var(--faint)}}
68.dot.acc{{background:var(--accent)}}
69/* header */
70header{{display:flex;align-items:center;flex-wrap:wrap;gap:9px 22px;
71padding:26px 0 18px;border-bottom:1px solid var(--line)}}
72h1{{font-family:var(--serif);font-size:23px;margin:0;letter-spacing:-.01em;
73font-weight:600;display:inline-flex;align-items:center;gap:9px}}
74.ic{{width:15px;height:15px;flex:none}}
75.mark{{width:18px;height:18px;flex:none;color:var(--accent)}}
76.hstatus{{display:flex;align-items:center;gap:7px;font-size:13px}}
77.hmeta{{margin-left:auto;display:flex;flex-wrap:wrap;align-items:center;
78gap:7px 16px;color:var(--mut);font-size:12px}}
79.srcs{{display:inline-flex;flex-wrap:wrap;align-items:center;gap:5px 14px}}
80.src{{display:inline-flex;align-items:center;gap:6px}}
81.themetgl{{background:transparent;border:1px solid var(--line);border-radius:
82999px;width:30px;height:30px;cursor:pointer;color:var(--mut);font-size:14px;
83line-height:1;display:inline-flex;align-items:center;justify-content:center;
84transition:border-color .14s,color .14s}}
85.themetgl:hover{{border-color:var(--fg);color:var(--fg)}}
86/* tabs (CSS-only radios) */
87.tabin{{position:absolute;opacity:0;pointer-events:none}}
88nav.tabbar{{display:flex;flex-wrap:wrap;gap:2px 6px;margin:16px 0 0;
89border-bottom:1px solid var(--line)}}
90nav.tabbar label{{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
91padding:10px 4px;margin:0 12px -1px 0;font-size:13.5px;font-weight:600;
92color:var(--mut);border-bottom:2px solid transparent;white-space:nowrap;
93transition:color .14s}}
94nav.tabbar label:hover{{color:var(--fg)}}
95nav.tabbar label.aside{{margin-left:auto}}
96nav.tabbar label .badge{{font-family:var(--mono);font-weight:500;font-size:11px;
97color:var(--faint)}}
98.panel{{display:none;padding:26px 0 0;animation:fade .16s ease}}
99@keyframes fade{{from{{opacity:.45}}to{{opacity:1}}}}
100/* gate hero */
101.hero{{display:grid;align-items:start;margin:0 0 4px;gap:22px 40px;
102grid-template-columns:minmax(330px,1.05fr) minmax(300px,1fr)}}
103@media(max-width:780px){{.hero{{grid-template-columns:1fr}}}}
104.heroh{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
105color:var(--mut);font-weight:600;margin:0 0 14px;padding-bottom:7px;
106border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px}}
107.gateflow{{display:flex;align-items:center;gap:14px;flex-wrap:wrap}}
108.bearings{{display:flex;flex-direction:column;min-width:172px;flex:1 1 172px}}
109.bearing{{display:flex;align-items:center;justify-content:space-between;gap:12px;
110padding:7px 0;border-bottom:1px solid var(--line);font-size:12.5px}}
111.bearing:last-child{{border-bottom:0}}
112.bearing .bl{{color:var(--mut)}}
113.bearing .bv{{display:inline-flex;align-items:center;gap:7px;font-weight:600;
114font-variant-numeric:tabular-nums}}
115.bearing.armed .bv{{color:var(--faint);font-weight:500}}
116.flowarrow{{color:var(--hair);font-size:17px;line-height:1}}
117.gatenode{{flex:0 0 auto;text-align:center;padding:2px 6px}}
118.gatenode .gl{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
119color:var(--mut);font-weight:600}}
120.gatenode .gv{{font-family:var(--serif);font-size:30px;font-weight:600;
121line-height:1.1;color:var(--fg);font-variant-numeric:tabular-nums}}
122.gatenode .gs{{font-size:11px;color:var(--faint)}}
123.outcome{{flex:0 0 auto;display:flex;flex-direction:column;gap:3px}}
124.outcome .ot{{display:flex;align-items:center;gap:7px;font-size:15px;
125font-weight:700;letter-spacing:.02em}}
126.outcome .os{{font-size:11px;color:var(--faint)}}
127.outcome.act .ot{{color:var(--ok)}}
128.outcome.hold .ot{{color:var(--mut)}}
129.caption{{color:var(--mut);font-size:12px;margin:14px 0 0;line-height:1.5;
130max-width:62ch}}
131/* per-class table inside the hero */
132.classes{{width:100%;border-collapse:collapse;font-size:12.5px}}
133.classes th,.classes td{{text-align:left;padding:7px 12px 7px 0;
134border-bottom:1px solid var(--line);vertical-align:baseline}}
135.classes th{{color:var(--faint);font-weight:600;font-size:10.5px;
136text-transform:uppercase;letter-spacing:.06em}}
137.classes td.r{{font-variant-numeric:tabular-nums}}
138.pill{{display:inline-flex;align-items:center;gap:6px;font-size:12px;
139font-weight:600}}
140.pill.ok{{color:var(--ok)}}.pill.hold{{color:var(--mut);font-weight:500}}
141/* metric strip — bare stats, no boxes; a hairline frames the row */
142.cards{{display:grid;gap:20px 30px;margin:26px 0 0;padding:18px 0 0;
143border-top:1px solid var(--line);
144grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
145.card .n{{font-size:26px;font-weight:600;line-height:1.05;
146font-variant-numeric:tabular-nums;letter-spacing:-.01em}}
147.card .l{{color:var(--mut);font-size:11px;text-transform:uppercase;
148letter-spacing:.05em;font-weight:600;margin-top:5px}}
149.card .x{{color:var(--faint);font-size:11.5px;margin-top:3px}}
150/* section + detail */
151.sec{{margin:30px 0 0}}
152.sech{{font-size:11px;text-transform:uppercase;letter-spacing:.1em;
153color:var(--mut);font-weight:600;margin:0 0 12px;padding-bottom:7px;
154border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}}
155.sech .n{{color:var(--faint);font-weight:500;letter-spacing:0;
156text-transform:none}}
157details.fold{{margin:14px 0 0;border-top:1px solid var(--line)}}
158details.fold>summary{{cursor:pointer;list-style:none;padding:11px 0;
159font-size:12px;font-weight:600;color:var(--mut);display:flex;
160align-items:center;gap:9px}}
161details.fold>summary:hover{{color:var(--fg)}}
162details.fold>summary::-webkit-details-marker{{display:none}}
163details.fold>summary::before{{content:"\\25B8";color:var(--hair);font-size:10px}}
164details.fold[open]>summary::before{{content:"\\25BE"}}
165details.fold>summary .c{{color:var(--faint);font-weight:500}}
166details.fold .body{{padding:2px 0 16px}}
167table.data{{width:100%;border-collapse:collapse;font-size:12.5px}}
168table.data th,table.data td{{text-align:left;padding:7px 14px 7px 0;
169border-bottom:1px solid var(--line);vertical-align:baseline}}
170table.data th{{color:var(--faint);font-weight:600;font-size:10.5px;
171text-transform:uppercase;letter-spacing:.05em}}
172table.data td.r{{font-variant-numeric:tabular-nums;white-space:nowrap}}
173.empty{{color:var(--faint);font-size:13px;padding:16px 0}}
174.t{{font-size:12px;color:var(--mut)}}
175.t.ok{{color:var(--ok)}}.t.warn{{color:var(--warn)}}.t.bad{{color:var(--bad)}}
176.cad{{color:var(--mut);font-size:12px;margin:18px 0 0}}
177.cad b{{color:var(--fg);font-weight:600}}
178footer{{margin:46px 0 0;padding-top:18px;border-top:1px solid var(--line);
179color:var(--mut);font-size:12px;line-height:1.7;max-width:92ch}}
180footer b{{color:var(--fg);font-weight:600}}
181"""
182 
183# The page's only script: set the saved theme before first paint (no flash) and
184# expose the toggle. Inline + tiny, so the page stays one self-contained file
185# with no external request; absent JS, the page simply follows the OS theme.
186_THEME_JS = (
187 "(function(){var k='froot-theme',r=document.documentElement,"
188 "s=localStorage.getItem(k);if(s)r.setAttribute('data-theme',s);"
189 "window.__toggleTheme=function(){var c=r.getAttribute('data-theme')||"
190 "(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');"
191 "var n=c==='dark'?'light':'dark';r.setAttribute('data-theme',n);"
192 "localStorage.setItem(k,n);};})();"
194 
195# A small set of hairline line-icons (inline SVG, stroke = currentColor, so they
196# inherit the muted ink and theme themselves). Anchors for the eye — tabs,
197# section headers, the wordmark — never on the number strip. Paths are 24x24.
198_ICONS = {
199 "package": (
200 '<path d="M21 8v8a2 2 0 0 1-1 1.73l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 '
201 '16V8a2 2 0 0 1 1-1.73l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8Z"/>'
202 '<path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'
203 ),
204 "shield": (
205 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
206 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
207 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>'
208 ),
209 "shield-check": (
210 '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 '
211 "4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 "
212 '0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>'
213 ),
214 "search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
215 "activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
216 "layers": (
217 '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 '
218 '3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>'
219 '<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/>'
220 '<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
221 ),
222 "inbox": (
223 '<path d="M22 12h-6l-2 3h-4l-2-3H2"/>'
224 '<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45'
225 '-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>'
226 ),
227 "lock": (
228 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
229 '<path d="M7 11V7a5 5 0 0 1 10 0v4"/>'
230 ),
231 "lock-open": (
232 '<rect width="18" height="11" x="3" y="11" rx="2"/>'
233 '<path d="M7 11V7a5 5 0 0 1 9.9-1"/>'
234 ),
235 "merge": (
236 '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/>'
237 '<path d="M6 21V9a9 9 0 0 0 9 9"/>'
238 ),
239 "scissors": (
240 '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/>'
241 '<path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/>'
242 '<path d="M14.8 14.8 20 20"/>'
243 ),
244 "accessibility": (
245 '<circle cx="16" cy="4" r="1"/><path d="m18 19 1-7-6 1"/>'
246 '<path d="m5 8 3-3 5.5 3-2.36 3.5"/>'
247 '<path d="M4.24 14.5a5 5 0 0 0 6.88 6"/>'
248 '<path d="M13.76 17.5a5 5 0 0 0-6.88-6"/>'
249 ),
251 
252 
253def _icon(name: str, cls: str = "ic") -> str:
254 return (
255 f'<svg class="{cls}" viewBox="0 0 24 24" fill="none" '
256 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
257 f'stroke-linejoin="round" aria-hidden="true">{_ICONS[name]}</svg>'
258 )
259 
260 
261def _wordmark() -> str:
262 """The froot mark: a fruit in the accent, with a single green leaf.
263 
264 Two-tone on purpose — the one spot color is allowed to be richer. The body
265 and stem ride the accent (currentColor); the leaf is the semantic green,
266 so the mark reads as a piece of fruit, not a logo abstraction.
267 """
268 return (
269 '<svg class="mark" viewBox="0 0 24 24" fill="none" '
270 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" '
271 'stroke-linejoin="round" aria-hidden="true">'
272 '<circle cx="11.5" cy="14.5" r="6.2"/>'
273 '<path d="M11.5 8.3V6.15" stroke="var(--ok)"/>'
274 '<path d="M11.5 6.15c1.4-2 3.5-2.6 5.8-2.2-.4 2.5-2.5 3.7-5.8 2.2z" '
275 'stroke="var(--ok)"/></svg>'
276 )
277 
278 
279_CI_CLASS = {
280 "passed": "ok",
281 "failed": "bad",
282 "absent": "mut",
283 "timed_out": "warn",
285_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
286_POST_MERGE_CLASS = {
287 "held": "ok",
288 "broke": "bad",
289 "reverted": "bad",
290 "unknown": "mut",
292_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
293_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
294 
295 
296# ── small formatters ─────────────────────────────────────────────────────────
297def _aware(when: datetime) -> datetime:
298 return when if when.tzinfo else when.replace(tzinfo=UTC)
299 
300 
301def _ago(when: datetime | None, now: datetime) -> str:
302 if when is None:
303 return ""
304 secs = (now - _aware(when)).total_seconds()
305 if secs < 90:
306 return "just now"
307 mins = secs / 60
308 if mins < 90:
309 return f"{round(mins)}m ago"
310 hours = mins / 60
311 if hours < 36:
312 return f"{round(hours)}h ago"
313 return f"{round(hours / 24)}d ago"
314 
315 
316def _until(when: datetime | None, now: datetime) -> str:
317 if when is None:
318 return ""
319 secs = (_aware(when) - now).total_seconds()
320 if secs <= 0:
321 return "due"
322 mins = secs / 60
323 if mins < 90:
324 return f"~{round(mins)}m"
325 hours = mins / 60
326 if hours < 36:
327 return f"~{round(hours)}h"
328 return f"~{round(hours / 24)}d"
329 
330 
331def _pct(rate: float | None) -> str:
332 return "" if rate is None else f"{rate * 100:.0f}%"
333 
334 
335def _dot(kind: str) -> str:
336 return f'<span class="dot {kind}"></span>'
337 
338 
339def _tag(value: str | None, classes: dict[str, str]) -> str:
340 if value is None:
341 return '<span class="t">—</span>'
342 cls = classes.get(value, "")
343 return f'<span class="t {cls}">{escape(value)}</span>'
344 
345 
346def _pr_link(row: BumpRow) -> str:
347 if row.pr_url is None or row.pr_number is None:
348 return '<span class="mut">—</span>'
349 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
350 
351 
352def _short_id(workflow_id: str) -> str:
353 return escape(workflow_id.removeprefix("froot-bump-"))
354 
355 
356# ── header ───────────────────────────────────────────────────────────────────
357def _alive(model: DashboardModel) -> tuple[str, str]:
358 """Global liveness dot + label across every loop."""
359 advisory_loops = [loop for v in model.advisory for loop in v.loops]
360 live = sum(1 for x in model.scan_loops if x.live) + sum(
361 1 for x in advisory_loops if x.live
362 )
363 total = len(model.scan_loops) + len(advisory_loops)
364 if total == 0:
365 return "mute", "no loops configured"
366 kind = "ok" if live == total else ("warn" if live else "bad")
⋯ 536 lines hidden (lines 367–902)
367 return kind, f"{live}/{total} loops live"
368 
369 
370def _header(model: DashboardModel) -> str:
371 kind, label = _alive(model)
372 sources = "".join(
373 f'<span class="src">{_dot("ok" if s.ok else "bad")}'
374 f"{escape(s.name)}</span>"
375 for s in model.sources
376 )
377 return (
378 "<header>"
379 f"<h1>{_wordmark()}froot</h1>"
380 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
381 f'<span class="hmeta"><span class="srcs">{sources}</span>'
382 '<button class="themetgl" type="button" onclick="__toggleTheme()"'
383 ' title="Toggle light / dark"'
384 ' aria-label="Toggle light or dark theme">&#9680;</button>'
385 "</span></header>"
386 )
387 
388 
389# ── the gate hero (per loop) ─────────────────────────────────────────────────
390def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
391 cls = "bearing armed" if armed else "bearing"
392 dot = _dot("mute" if armed else kind)
393 return (
394 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
395 f'<span class="bv {kind}">{dot}{escape(value)}</span></div>'
396 )
397 
398 
399def _gate_hero(view: LoopView) -> str:
400 t, rel, pr = view.track_record, view.reliability, view.probes
401 earned = sum(1 for g in view.class_gates if g.earned)
402 total = len(view.class_gates)
403 acting = any(r.would_auto_merge for r in view.gate)
404 # The four bearings: rate + defect come from the record; the adversarial
405 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
406 # the merge, so it is shown armed (always-on), not a record figure.
407 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
408 defect_ok = "ok" if not rel.defect_rate else "bad"
409 probe_ok = "ok" if pr.escaped == 0 else "bad"
410 bearings = "".join(
411 (
412 _bearing(
413 "approval rate",
414 _pct(t.merge_rate),
415 rate_ok if t.merge_rate is not None else "mut",
416 ),
417 _bearing(
418 "defect rate",
419 _pct(rel.defect_rate),
420 defect_ok if rel.defect_rate is not None else "mut",
421 ),
422 _bearing(
423 "probe",
424 f"{pr.escaped} escaped" if pr.total else "none",
425 probe_ok if pr.total else "mut",
426 ),
427 _bearing("deep review", "armed", "mut", armed=True),
428 )
429 )
430 if total == 0:
431 node = (
432 '<div class="gatenode"><div class="gl">gate</div>'
433 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
434 )
435 else:
436 node = (
437 f'<div class="gatenode"><div class="gl">earned</div>'
438 f'<div class="gv">{earned}/{total}</div>'
439 '<div class="gs">classes</div></div>'
440 )
441 if acting:
442 out = (
443 f'<div class="outcome act"><div class="ot">{_icon("merge")}'
444 "AUTO-MERGE</div>"
445 '<div class="os">a class is acting now</div></div>'
446 )
447 elif earned:
448 out = (
449 f'<div class="outcome act"><div class="ot">{_icon("lock-open")}'
450 "EARNED</div>"
451 '<div class="os">acts where allowlisted</div></div>'
452 )
453 else:
454 out = (
455 f'<div class="outcome hold"><div class="ot">{_icon("lock")}HOLD'
456 "</div><div class="
457 '"os">building the record</div></div>'
458 )
459 flow = (
460 '<div class="gateflow">'
461 f'<div class="bearings">{bearings}</div>'
462 '<div class="flowarrow">&rarr;</div>'
463 f"{node}"
464 '<div class="flowarrow">&rarr;</div>'
465 f"{out}</div>"
466 )
467 caption = (
468 '<p class="caption">A class earns the gate by triangulation: a high '
469 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
470 "evidence. Two further legs guard the live merge — an adversarial "
471 "<b>probe</b> and an independent <b>deep review</b> at merge. "
472 "Auto-merge is allowlist-gated (off by default).</p>"
473 )
474 return (
475 f'<div><div class="heroh">{_icon("shield-check")}'
476 "Earned autonomy &middot; the gate</div>"
477 f"{flow}{caption}</div>"
478 )
479 
480 
481def _class_table(view: LoopView) -> str:
482 if not view.class_gates:
483 return (
484 f'<div><div class="heroh">{_icon("layers")}Classes</div>'
485 '<div class="empty">No classes yet &mdash; a (repo, loop) '
486 "earns the gate from its own track record.</div></div>"
487 )
488 rows = "".join(_class_row(g) for g in view.class_gates)
489 return (
490 f'<div><div class="heroh">{_icon("layers")}Per-class standing</div>'
491 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
492 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
493 f"<tbody>{rows}</tbody></table></div>"
494 )
495 
496 
497def _class_row(g: ClassGate) -> str:
498 if g.earned:
499 gate = f'<span class="pill ok">{_dot("ok")}earned</span>'
500 else:
501 gate = (
502 f'<span class="pill hold">{_dot("mute")}hold</span> '
503 f'<span class="mut">{escape(g.blocker or "")}</span>'
504 )
505 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
506 budget = (
507 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
508 if g.approvals_per_week
509 else ""
510 )
511 return (
512 f'<tr><td class="mono">{escape(g.repo)}</td>'
513 f'<td class="r">{_pct(g.merge_rate)}</td>'
514 f'<td class="r">{escape(defect)}</td>'
515 f"<td>{gate}</td>"
516 f'<td class="r mut">{escape(budget)}</td></tr>'
517 )
518 
519 
520# ── metric cards (per loop) ──────────────────────────────────────────────────
521def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
522 x = f'<div class="x">{extra}</div>' if extra else ""
523 return (
524 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
525 f'<div class="l">{escape(label)}</div>{x}</div>'
526 )
527 
528 
529def _loop_cards(view: LoopView) -> str:
530 t, v, rel, j, pr = (
531 view.track_record,
532 view.verification,
533 view.reliability,
534 view.judgment,
535 view.probes,
536 )
537 defects = rel.broke + rel.reverted
538 cards = [
539 _card(
540 t.opened,
541 "proposed",
542 f"{t.merged} merged · {t.closed_unmerged} closed",
543 ),
544 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
545 _card(
546 t.open_now,
547 "awaiting you",
548 "open, needs a human",
549 "warn" if t.open_now else "",
550 ),
551 _card(
552 _pct(rel.defect_rate) if rel.determined else "",
553 "defect rate",
554 f"{rel.held} held · {defects} broke",
555 "bad" if defects else "",
556 ),
557 _card(
558 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
559 "CI passed",
560 "the oracle",
561 ),
562 _card(
563 pr.escaped if pr.total else "0",
564 "probes escaped",
565 f"{pr.caught} caught" if pr.total else "no probes yet",
566 "bad" if pr.escaped else "",
567 ),
568 _card(
569 j.clean,
570 "clean verdicts",
571 f"{j.clean_but_failed} mis-judged"
572 if j.clean_but_failed
573 else "judge calibrated",
574 ),
575 _card(rel.held, "merges held", "post-merge, stayed green"),
576 ]
577 return f'<div class="cards">{"".join(cards)}</div>'
578 
579 
580# ── detail tables ────────────────────────────────────────────────────────────
581def _cadence(view: LoopView, now: datetime) -> str:
582 live = sum(1 for s in view.scan_loops if s.live)
583 total = len(view.scan_loops)
584 nxt = ""
585 last = max(
586 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
587 default=None,
588 )
589 if last is not None:
590 due = last + timedelta(seconds=view.scan_interval_seconds)
591 nxt = f" &middot; next {escape(_until(due, now))}"
592 every = round(view.scan_interval_seconds / 3600, 1)
593 return (
594 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
595 f"&middot; every <b>{every}h</b>{nxt}</p>"
596 )
597 
598 
599def _bumps_fold(view: LoopView) -> str:
600 if not view.bumps:
601 return ""
602 rows = "".join(
603 "<tr>"
604 f'<td class="mono">{escape(r.package)}</td>'
605 f'<td class="mono mut">{escape(r.to_version)}</td>'
606 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
607 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
608 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
609 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
610 f"<td>{_pr_link(r)}</td>"
611 "</tr>"
612 for r in view.bumps
613 )
614 return (
615 '<details class="fold"><summary>Bumps '
616 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
617 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
618 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
619 "<th>pr</th></tr></thead>"
620 f"<tbody>{rows}</tbody></table></div></details>"
621 )
622 
623 
624def _failures_fold(view: LoopView) -> str:
625 if not view.failures:
626 return ""
627 rows = "".join(
628 "<tr>"
629 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
630 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
631 f'<td class="mut">{escape(f.reason or "")}</td>'
632 "</tr>"
633 for f in view.failures
634 )
635 return (
636 '<details class="fold"><summary class="bad">Failures '
637 f'<span class="c">{len(view.failures)}</span></summary>'
638 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
639 "<th>kind</th><th>reason</th></tr></thead>"
640 f"<tbody>{rows}</tbody></table></div></details>"
641 )
642 
643 
644# ── panels ───────────────────────────────────────────────────────────────────
645def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
646 queue = _queue_sec(view)
647 return (
648 f'<section class="panel" id="{pid}">'
649 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
650 f"{_loop_cards(view)}"
651 f"{queue}"
652 f"{_cadence(view, now)}"
653 f"{_bumps_fold(view)}{_failures_fold(view)}"
654 "</section>"
655 )
656 
657 
658def _queue_sec(view: LoopView) -> str:
659 if not view.gate:
660 return (
661 f'<div class="sec"><div class="sech">{_icon("inbox")}'
662 'Approval queue <span class="n">empty</span></div>'
663 '<div class="empty">Nothing awaiting you.</div></div>'
664 )
665 rows = "".join(
666 "<tr>"
667 f'<td class="mono">{escape(r.package)}</td>'
668 f'<td class="mono mut">{escape(r.to_version)}</td>'
669 f"<td>{_queue_badge(r)}</td>"
670 f"<td>{_pr_link(r)}</td>"
671 "</tr>"
672 for r in view.gate
673 )
674 return (
675 f'<div class="sec"><div class="sech">{_icon("inbox")}Approval queue '
676 f'<span class="n">{len(view.gate)} yours</span></div>'
677 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
678 "<th>gate</th><th>pr</th></tr></thead>"
679 f"<tbody>{rows}</tbody></table></div>"
680 )
681 
682 
683def _queue_badge(row: BumpRow) -> str:
684 if row.would_auto_merge:
685 return f'<span class="pill ok">{_dot("ok")}would auto-merge</span>'
686 reason = row.held_reason or "held"
687 return f'<span class="mut">held &middot; {escape(reason)}</span>'
688 
689 
690def _advisory_panel(view: AdvisoryView, pid: str) -> str:
691 """One advisory loop's tab, presentation (icon, title) from its spec."""
692 r = view.record
693 live = sum(1 for x in view.loops if x.live)
694 flag = "bad" if r.findings else ""
695 cards = (
696 '<div class="cards">'
697 f"{_card(r.repos_covered, 'repos covered')}"
698 f"{_card(f'{live}/{len(view.loops)}', 'loops live')}"
699 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
700 f"{_card(r.findings, 'findings', 'advisory', flag)}"
701 "</div>"
702 )
703 body = (
704 '<div class="empty">No PRs reviewed yet.</div>'
705 if not view.rows
706 else _advisory_table(view.rows)
707 )
708 note = (
709 '<p class="caption">An advisory loop. froot re-derives each open '
710 "PR's findings every tick and upserts one decaying comment, never "
711 "blocking a merge.</p>"
712 )
713 every = round(view.interval_seconds / 60, 1)
714 cad = (
715 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
716 f"every <b>{every}m</b></p>"
717 )
718 return (
719 f'<section class="panel" id="{pid}">'
720 f'<div class="heroh">{_icon(view.icon)}{escape(view.title)}</div>'
721 f"{note}{cards}{cad}"
722 f'<div class="sec"><div class="sech">Reviews '
723 f'<span class="n">{len(view.rows)}</span></div>{body}</div>'
724 "</section>"
725 )
726 
727 
728def _advisory_table(rows: tuple[AdvisoryRow, ...]) -> str:
729 body = "".join(
730 "<tr>"
731 f'<td class="mono">{escape(row.repo)}</td>'
732 f"<td>{_advisory_pr_link(row)}</td>"
733 f"<td>{_advisory_findings_cell(row)}</td>"
734 f'<td class="mono mut">{escape(", ".join(row.detail) or "")}</td>'
735 "</tr>"
736 for row in rows
737 )
738 return (
739 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
740 "<th>findings</th><th>details</th></tr></thead>"
741 f"<tbody>{body}</tbody></table>"
742 )
743 
744 
745def _advisory_pr_link(row: AdvisoryRow) -> str:
746 if row.pr_url is None or row.pr_number is None:
747 return '<span class="mut">—</span>'
748 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
749 
750 
751def _advisory_findings_cell(row: AdvisoryRow) -> str:
752 if row.findings == 0:
753 return '<span class="ok">clean</span>'
754 label = "finding" if row.findings == 1 else "findings"
755 link = ""
756 if row.comment_url:
757 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
758 return f'<span class="bad">{row.findings} {label}</span>{link}'
759 
760 
761def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
762 tel: RunTelemetry = model.telemetry
763 if not tel.available:
764 body = (
765 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
766 "froot traces in the window.</div>"
767 )
768 else:
769 rows = "".join(
770 "<tr>"
771 f'<td class="mono">{escape(a.name)}</td>'
772 f'<td class="r">{a.count}</td>'
773 f'<td class="r">{a.avg_ms:.0f} ms</td>'
774 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
775 "</tr>"
776 for a in tel.activities
777 )
778 last = f"last {_ago(tel.last_activity, now)}"
779 err = "bad" if tel.error_spans else ""
780 head = (
781 '<div class="cards">'
782 f"{_card(tel.total_spans, 'spans', last)}"
783 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
784 f"{_card(f'{tel.window_days}d', 'window')}</div>"
785 )
786 table = (
787 '<div class="sec"><div class="sech">Activity latency</div>'
788 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
789 "<th>avg</th><th>max</th></tr></thead>"
790 f"<tbody>{rows}</tbody></table></div>"
791 )
792 body = head + table
793 note = (
794 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
795 "(trace-derived, best-effort) — latency per activity across "
796 "every loop.</p>"
797 )
798 return (
799 f'<section class="panel" id="{pid}">'
800 f'<div class="heroh">{_icon("activity")}Run telemetry '
801 "&middot; ClickHouse</div>"
802 f"{note}{body}</section>"
803 )
804 
805 
806# ── tabs + page ──────────────────────────────────────────────────────────────
807def _loop_badge(view: LoopView) -> str:
808 earned = sum(1 for g in view.class_gates if g.earned)
809 if view.class_gates and earned:
810 return f"{earned}/{len(view.class_gates)} earned"
811 if view.track_record.open_now:
812 return f"{view.track_record.open_now} open"
813 return str(view.track_record.opened)
814 
815 
816def page(model: DashboardModel) -> str:
817 """Render the whole dashboard as one self-contained HTML document."""
818 now = model.generated_at
819 # (tab-id, panel-id, icon, label, badge, panel-html)
820 tabs: list[tuple[str, str, str, str, str, str]] = []
821 for i, view in enumerate(model.bump_loops):
822 pid, tid = f"panel-{i}", f"tab-{i}"
823 icon = view.icon
824 tabs.append(
825 (
826 tid,
827 pid,
828 icon,
829 view.title,
830 _loop_badge(view),
831 _loop_panel(view, pid, now),
832 )
833 )
834 for j, advisory in enumerate(model.advisory):
835 pid, tid = f"panel-adv-{j}", f"tab-adv-{j}"
836 tabs.append(
837 (
838 tid,
839 pid,
840 advisory.icon,
841 advisory.title,
842 str(advisory.record.reviewed),
843 _advisory_panel(advisory, pid),
844 )
845 )
846 tabs.append(
847 (
848 "tab-tel",
849 "panel-tel",
850 "activity",
851 "Telemetry",
852 "live" if model.telemetry.available else "off",
853 _telemetry_panel(model, "panel-tel", now),
854 )
855 )
856 
857 inputs, labels, panels, rules = [], [], [], []
858 for idx, (tid, pid, icon, label, badge, panel) in enumerate(tabs):
859 checked = " checked" if idx == 0 else ""
860 inputs.append(
861 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
862 )
863 # Telemetry is cross-cutting, not a loop — float it to the far right.
864 lcls = ' class="aside"' if tid == "tab-tel" else ""
865 labels.append(
866 f'<label{lcls} for="{tid}">{_icon(icon)}{escape(label)}'
867 f'<span class="badge">{escape(badge)}</span></label>'
868 )
869 panels.append(panel)
870 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
871 rules.append(
872 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
873 "{color:var(--fg);border-bottom-color:var(--accent)}"
874 )
875 tabcss = "".join(rules)
876 return (
877 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
878 '<meta name="viewport" content="width=device-width,initial-scale=1">'
879 "<title>froot &middot; read-model</title>"
880 f"<style>{_CSS}{tabcss}</style>"
881 f"<script>{_THEME_JS}</script></head><body>"
882 + "".join(inputs)
883 + "<main>"
884 + _header(model)
885 + f'<nav class="tabbar">{"".join(labels)}</nav>'
886 + "".join(panels)
887 + _footer()
888 + "</main></body></html>"
889 )
890 
891 
892def _footer() -> str:
893 return (
894 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
895 "auto-merges only on an allowlisted repo where a class has earned its "
896 "gate (the allowlist is empty by default, so commit authority is none "
897 "until a steward opts in). Trust is earned, narrow to one loop on one "
898 "repo, conditional on its environment "
899 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
900 "Everything here is derived per request from GitHub + Temporal + "
901 "ClickHouse; froot keeps no database.</footer>"
902 )

The per-loop config stays at the edge

Each advisory loop keeps its own poll-interval setting. server.py reads both and passes them as a loop -> seconds map; assemble takes the map and the view builder reads each loop's own cadence. The bespoke config read stays at the edge; the derivation stays generic.

src/froot/dashboard/server.py · 242 lines
src/froot/dashboard/server.py242 lines · Python
⋯ 122 lines hidden (lines 1–122)
1"""The dashboard HTTP server — a tiny dependency-free asyncio responder.
2 
3Runs on the worker's own event loop (same process, same pod) and reuses the
4worker's connected Temporal client. On each GET it fans the three readers out
5concurrently, assembles the view, and renders one self-contained page; it stores
6nothing between requests. GET-only, ``Connection: close``, ``Cache-Control:
7no-store`` — built for a single viewer behind ``kubectl port-forward``, not the
8public internet. Every reader degrades to an error string, so a source being
9down yields a page with a red dot, never a crash.
10"""
11 
12from __future__ import annotations
13 
14import asyncio
15import contextlib
16import logging
17from datetime import UTC, datetime
18from typing import TYPE_CHECKING, Final
19 
20from froot.config.settings import (
21 A11yReviewSettings,
22 AutonomySettings,
23 DashboardSettings,
24 ReviewSettings,
25 Settings,
27from froot.dashboard import (
28 clickhouse_source,
29 github_source,
30 read_model,
31 render,
32 temporal_source,
34from froot.domain.loop import Loop
35from froot.policy.autonomy import AutonomyPolicy
36 
37if TYPE_CHECKING:
38 from temporalio.client import Client
39 
40_log = logging.getLogger("froot.dashboard")
41 
42_READ_TIMEOUT: Final = 15.0
43_MAX_HEAD_BYTES: Final = 64 * 1024
44# Settings' own default, repeated here so a missing FROOT_REPOS still renders.
45_DEFAULT_INTERVAL: Final = 86_400
46_DEFAULT_REVIEW_INTERVAL: Final = 300
47_DEFAULT_A11Y_INTERVAL: Final = 300
48 
49 
50def _config() -> tuple[tuple[str, ...], tuple[Loop, ...], int]:
51 """The watched repos, active loops, and scan interval (empty if unset)."""
52 try:
53 settings = Settings()
54 except Exception: # FROOT_REPOS unset/invalid — show an empty heartbeat
55 return (), (Loop.DEPENDENCY_PATCH,), _DEFAULT_INTERVAL
56 repos = tuple(target.repo.slug for target in settings.repos)
57 return repos, settings.loops, settings.scan_interval_seconds
58 
59 
60def _review_interval() -> int:
61 """The determinism-review poll cadence, degrading to the default."""
62 try:
63 return ReviewSettings().poll_interval_seconds
64 except Exception: # never let a config read fail the page
65 return _DEFAULT_REVIEW_INTERVAL
66 
67 
68def _a11y_interval() -> int:
69 """The source-level a11y-review poll cadence, degrading to the default."""
70 try:
71 return A11yReviewSettings().poll_interval_seconds
72 except Exception: # never let a config read fail the page
73 return _DEFAULT_A11Y_INTERVAL
74 
75 
76def _environment() -> str:
77 """The current judgment-environment slug (the judge model), for the gate.
78 
79 Trust is counted only under the current environment (§3.7); degrades to an
80 empty slug, which simply matches no stamped PR (the safe, reset side).
81 """
82 try:
83 from froot.config.settings import ModelSettings
84 from froot.policy.environment import environment_slug
85 
86 return environment_slug(ModelSettings().ollama_model)
87 except Exception: # never let a config read fail the page
88 return ""
89 
90 
91def _autonomy_policy() -> AutonomyPolicy:
92 """The earned-autonomy thresholds, degrading to safe defaults.
93 
94 A bad ``FROOT_AUTOMERGE_*`` value must never blank the page: the fallback
95 is the conservative default with an empty allowlist, so the shadow gate
96 simply holds everything rather than erroring.
97 """
98 try:
99 return AutonomySettings().policy()
100 except Exception: # never let a config read fail the page
101 return AutonomyPolicy()
102 
103 
104async def build_html(client: Client) -> str:
105 """Derive the whole view live and render it (the per-request work)."""
106 now = datetime.now(UTC)
107 repos, loops, interval = _config()
108 policy = _autonomy_policy()
109 github_result, temporal_result, telemetry_result = await asyncio.gather(
110 github_source.fetch(repos),
111 temporal_source.fetch(client),
112 clickhouse_source.fetch(),
113 )
114 # The post-merge outcome leg needs the merged PRs first, so it runs after
115 # the GitHub read (a handful of extra calls, best-effort). It shares the
116 # autonomy window so "recent" means the same thing across the page.
117 prs, _ = github_result
118 outcomes, outcome_error = await github_source.fetch_outcomes(
119 repos, prs, now=now, window_days=policy.window_days
120 )
121 if outcome_error is not None:
122 _log.warning("post-merge outcome read degraded: %s", outcome_error)
123 model = read_model.assemble(
124 now=now,
125 repos=repos,
126 loops=loops,
127 policy=policy,
128 scan_interval_seconds=interval,
129 advisory_intervals={
130 Loop.DETERMINISM_REVIEW: _review_interval(),
131 Loop.A11Y_REVIEW: _a11y_interval(),
132 },
133 github=github_result,
⋯ 109 lines hidden (lines 134–242)
134 temporal=temporal_result,
135 telemetry=telemetry_result,
136 outcomes=outcomes,
137 reliability_window_days=policy.window_days,
138 environment=_environment(),
139 )
140 return render.page(model)
141 
142 
143def _parse_path(request_line: bytes) -> tuple[str, str]:
144 """Return ``(method, path)`` from a raw HTTP request line."""
145 parts = request_line.decode("latin-1", "replace").split()
146 if len(parts) < 2:
147 return "", "/"
148 return parts[0].upper(), parts[1].split("?", 1)[0]
149 
150 
151async def _drain_headers(reader: asyncio.StreamReader) -> None:
152 """Read and discard the request headers, bounded in size and time."""
153 total = 0
154 while True:
155 line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
156 total += len(line)
157 if line in (b"\r\n", b"\n", b"") or total > _MAX_HEAD_BYTES:
158 return
159 
160 
161async def _respond(
162 writer: asyncio.StreamWriter,
163 status: str,
164 content_type: str,
165 body: bytes,
166) -> None:
167 """Write a complete HTTP/1.1 response and close the connection."""
168 head = (
169 f"HTTP/1.1 {status}\r\n"
170 f"Content-Type: {content_type}\r\n"
171 f"Content-Length: {len(body)}\r\n"
172 "Cache-Control: no-store\r\n"
173 "Connection: close\r\n\r\n"
174 ).encode("latin-1")
175 writer.write(head + body)
176 await writer.drain()
177 
178 
179async def _handle(
180 reader: asyncio.StreamReader,
181 writer: asyncio.StreamWriter,
182 client: Client,
183) -> None:
184 """Serve one request: route a GET to the dashboard, else 404/405/500."""
185 try:
186 request_line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
187 await _drain_headers(reader)
188 method, path = _parse_path(request_line)
189 if method != "GET":
190 await _respond(writer, "405 Method Not Allowed", "text/plain", b"")
191 elif path in ("/", "/index.html", "/dashboard"):
192 html = await build_html(client)
193 await _respond(
194 writer, "200 OK", "text/html; charset=utf-8", html.encode()
195 )
196 elif path == "/healthz":
197 await _respond(writer, "200 OK", "text/plain", b"ok")
198 else:
199 await _respond(writer, "404 Not Found", "text/plain", b"")
200 except (TimeoutError, ConnectionError):
201 pass
202 except Exception as exc: # never let a request take the worker down
203 _log.exception("dashboard request failed")
204 with contextlib.suppress(Exception):
205 await _respond(
206 writer,
207 "500 Internal Server Error",
208 "text/html; charset=utf-8",
209 _error_html(exc).encode(),
210 )
211 finally:
212 with contextlib.suppress(Exception):
213 writer.close()
214 await writer.wait_closed()
215 
216 
217def _error_html(exc: Exception) -> str:
218 """A minimal error page (the detail is for a human at a port-forward)."""
219 from html import escape
220 
221 return (
222 "<!doctype html><meta charset=utf-8><title>froot &middot; error</title>"
223 '<body style="font:15px system-ui;max-width:640px;margin:40px auto">'
224 "<h1>froot dashboard error</h1><p>Could not build the read-model.</p>"
225 f"<pre>{escape(type(exc).__name__)}: {escape(str(exc))}</pre></body>"
226 )
227 
228 
229async def start(client: Client) -> asyncio.Server:
230 """Start the dashboard server on the configured host/port (returns it)."""
231 settings = DashboardSettings()
232 
233 async def on_connect(
234 reader: asyncio.StreamReader, writer: asyncio.StreamWriter
235 ) -> None:
236 await _handle(reader, writer, client)
237 
238 server = await asyncio.start_server(
239 on_connect, host=settings.host, port=settings.port
240 )
241 _log.info("dashboard listening on %s:%d", settings.host, settings.port)
242 return server
src/froot/dashboard/read_model.py · 861 lines
src/froot/dashboard/read_model.py861 lines · Python
⋯ 781 lines hidden (lines 1–781)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 AdvisoryLoop,
18 AdvisoryRecord,
19 AdvisoryRow,
20 AdvisoryView,
21 BumpRow,
22 ClassGate,
23 DashboardModel,
24 Failure,
25 Judgment,
26 LoopView,
27 Probes,
28 Reliability,
29 RunTelemetry,
30 ScanLoop,
31 SourceHealth,
32 TrackRecord,
33 Verification,
35from froot.domain.loop import Loop
36from froot.domain.repo import RepoRef, TargetRepo
37from froot.loops import registry
38from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
39from froot.policy.canary import is_canary, score_probe
40from froot.policy.naming import (
41 a11y_review_workflow_id,
42 review_workflow_id,
43 scan_workflow_id,
45from froot.result import Ok
46 
47if TYPE_CHECKING:
48 from datetime import datetime
49 
50 from froot.dashboard.github_source import GithubPr
51 from froot.dashboard.temporal_source import (
52 AdvisoryExecution,
53 BumpExecution,
54 PrAdvisoryExecution,
55 ScanExecution,
56 )
57 
58# The conservative fallback policy: empty allowlist, so the shadow gate holds
59# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
60_DEFAULT_POLICY = AutonomyPolicy()
61 
62_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
63# Every way a bump can end without closing cleanly — all belong in the honest
64# Failures panel, not silently dropped.
65_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
66 
67 
68def _median(values: list[float]) -> float | None:
69 """The median of ``values``, or ``None`` if empty (pure)."""
70 if not values:
71 return None
72 ordered = sorted(values)
73 mid = len(ordered) // 2
74 if len(ordered) % 2 == 1:
75 return ordered[mid]
76 return (ordered[mid - 1] + ordered[mid]) / 2
77 
78 
79def _scan_id(repo: str, loop: Loop) -> str | None:
80 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
81 match RepoRef.parse(repo):
82 case Ok(ref):
83 return scan_workflow_id(TargetRepo(repo=ref), loop)
84 case _:
85 return None
86 
87 
88def _scan_loops(
89 repos: tuple[str, ...],
90 loops: tuple[Loop, ...],
91 scans: tuple[ScanExecution, ...],
92) -> tuple[ScanLoop, ...]:
93 """One liveness row per configured (repo, loop) (latest execution wins)."""
94 by_id: dict[str, ScanExecution] = {}
95 for scan in scans:
96 current = by_id.get(scan.workflow_id)
97 if current is None or _newer(scan.start, current.start):
98 by_id[scan.workflow_id] = scan
99 rows: list[ScanLoop] = []
100 for loop in loops:
101 for repo in repos:
102 scan_id = _scan_id(repo, loop)
103 execution = by_id.get(scan_id) if scan_id is not None else None
104 if execution is None:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status="none",
110 live=False,
111 last_tick=None,
112 )
113 )
114 else:
115 rows.append(
116 ScanLoop(
117 repo=repo,
118 loop=loop.value,
119 status=execution.status,
120 live=execution.status in _LIVE_STATUSES,
121 last_tick=execution.start,
122 )
123 )
124 return tuple(rows)
125 
126 
127def _newer(a: datetime | None, b: datetime | None) -> bool:
128 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
129 if a is None:
130 return False
131 if b is None:
132 return True
133 return a > b
134 
135 
136def _bump_rows(
137 now: datetime,
138 prs: tuple[GithubPr, ...],
139 bumps: tuple[BumpExecution, ...],
140 outcomes: dict[tuple[str, int], str],
141) -> tuple[BumpRow, ...]:
142 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number.
143 
144 ``outcomes`` carries the post-merge signal (``held`` / ``broke`` /
145 ``reverted`` / ``unknown``) per recently-merged ``(repo, number)``; it is
146 absent for older or non-merged PRs, which leaves ``post_merge`` ``None``.
147 """
148 # Keyed on (repo, PR number), not the bare number: Temporal lists every
149 # repo's bumps in the namespace, so two repos that each have a PR #N would
150 # otherwise cross-attribute one's verdict/CI onto the other's row.
151 by_pr: dict[tuple[str, int], BumpExecution] = {
152 (bump.repo, bump.pr_number): bump
153 for bump in bumps
154 if bump.repo is not None and bump.pr_number is not None
155 }
156 rows: list[BumpRow] = []
157 for pr in prs:
158 execution = by_pr.get((pr.repo, pr.number))
159 verdict = (execution.verdict if execution else None) or pr.verdict
160 ci = execution.ci if execution else None
161 ttm = _minutes_between(pr.opened_at, pr.merged_at)
162 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
163 # A removal carries no version: show "unused" in the target column and
164 # no from-version, so a dead-code row reads "left-pad -> unused" rather
165 # than the bump-shaped "left-pad -> ?".
166 is_removal = pr.loop == Loop.DEAD_CODE.value
167 rows.append(
168 BumpRow(
169 repo=pr.repo,
170 loop=pr.loop,
171 package=pr.package or "?",
172 from_version=None if is_removal else pr.from_version,
173 to_version="unused" if is_removal else (pr.to_version or "?"),
174 state=pr.state,
175 verdict=verdict,
176 ci=ci,
177 pr_number=pr.number,
178 pr_url=pr.url,
179 opened_at=pr.opened_at,
180 merged_at=pr.merged_at,
181 ttm_minutes=ttm,
182 age_hours=age,
183 post_merge=outcomes.get((pr.repo, pr.number)),
184 env=pr.env,
185 )
186 )
187 rows.sort(key=_opened_sort_key, reverse=True)
188 return tuple(rows)
189 
190 
191def _opened_sort_key(row: BumpRow) -> float:
192 """Sort key putting the most recently opened PR first (unknown last)."""
193 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
194 
195 
196def _minutes_between(
197 start: datetime | None, end: datetime | None
198) -> float | None:
199 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
200 if start is None or end is None:
201 return None
202 return round((end - start).total_seconds() / 60, 1)
203 
204 
205def _hours_between(
206 start: datetime | None, end: datetime | None
207) -> float | None:
208 """Hours from ``start`` to ``end``, or ``None``."""
209 if start is None or end is None:
210 return None
211 return round((end - start).total_seconds() / 3600, 1)
212 
213 
214def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
215 """Counts, merge rate, and median time-to-merge from the bump rows."""
216 merged = [r for r in rows if r.state == "merged"]
217 closed = sum(1 for r in rows if r.state == "closed")
218 open_now = sum(1 for r in rows if r.state == "open")
219 decided = len(merged) + closed
220 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
221 return TrackRecord(
222 opened=len(rows),
223 merged=len(merged),
224 closed_unmerged=closed,
225 open_now=open_now,
226 merge_rate=(len(merged) / decided) if decided else None,
227 median_ttm_minutes=_median(ttms),
228 )
229 
230 
231def _verification(rows: tuple[BumpRow, ...]) -> Verification:
232 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
233 passed = sum(1 for r in rows if r.ci == "passed")
234 failed = sum(1 for r in rows if r.ci == "failed")
235 absent = sum(1 for r in rows if r.ci == "absent")
236 timed_out = sum(1 for r in rows if r.ci == "timed_out")
237 unknown = sum(1 for r in rows if r.ci is None)
238 return Verification(
239 passed=passed,
240 failed=failed,
241 absent=absent,
242 timed_out=timed_out,
243 unknown=unknown,
244 oracle_existed=passed + failed,
245 with_reading=passed + failed + absent + timed_out,
246 )
247 
248 
249def _reliability(rows: tuple[BumpRow, ...], window_days: int) -> Reliability:
250 """The post-merge outcome breakdown — did the merges hold? (coarse).
251 
252 Reads only the ``post_merge`` tag the outcome reader set: ``unknown`` means
253 an in-window merge we could not classify (no branch oracle / aged out), kept
254 distinct from a held one; ``None`` (older / non-merged) is not counted here.
255 The defect rate is a floor — manual and bundled reverts are invisible.
256 """
257 held = sum(1 for r in rows if r.post_merge == "held")
258 broke = sum(1 for r in rows if r.post_merge == "broke")
259 reverted = sum(1 for r in rows if r.post_merge == "reverted")
260 unverified = sum(1 for r in rows if r.post_merge == "unknown")
261 determined = held + broke + reverted
262 return Reliability(
263 held=held,
264 broke=broke,
265 reverted=reverted,
266 unverified=unverified,
267 determined=determined,
268 defect_rate=((broke + reverted) / determined) if determined else None,
269 window_days=window_days,
270 )
271 
272 
273def _probes(canary_rows: tuple[BumpRow, ...]) -> Probes:
274 """Tally the adversarial canary probes — caught / escaped / pending.
275 
276 These rows are the synthetic bad bumps; they are scored on the strict bar
277 (a canary must never merge) and kept out of every genuine bearing.
278 """
279 scored = [score_probe(r.state) for r in canary_rows]
280 return Probes(
281 caught=sum(1 for s in scored if s == "caught"),
282 escaped=sum(1 for s in scored if s == "escaped"),
283 pending=sum(1 for s in scored if s == "pending"),
284 total=len(scored),
285 )
286 
287 
288def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
289 """The verdict mix plus the two calibration cells worth flagging."""
290 clean = sum(1 for r in rows if r.verdict == "clean")
291 risky = sum(1 for r in rows if r.verdict == "risky")
292 unknown = sum(1 for r in rows if r.verdict == "unknown")
293 none = sum(1 for r in rows if r.verdict is None)
294 clean_but_failed = sum(
295 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
296 )
297 flagged_but_passed = sum(
298 1
299 for r in rows
300 if r.verdict in ("risky", "unknown") and r.ci == "passed"
301 )
302 return Judgment(
303 clean=clean,
304 risky=risky,
305 unknown=unknown,
306 none=none,
307 clean_but_failed=clean_but_failed,
308 flagged_but_passed=flagged_but_passed,
309 )
310 
311 
312def _decided_at(row: BumpRow) -> datetime | None:
313 """When a decided PR was decided — merge time, else open time as a proxy.
314 
315 A merged PR has an exact merge instant; a closed-unmerged one does not (the
316 issues list froot reads carries no close timestamp), so it is windowed by
317 when it opened. The proxy only nudges a closed PR's window membership by its
318 own lifetime — close enough for a recency window measured in months.
319 """
320 return row.merged_at or row.opened_at
321 
322 
323def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
324 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
325 if when is None:
326 return False
327 return (now - when).total_seconds() <= window_days * 86400.0
328 
329 
330def _class_gates(
331 now: datetime,
332 rows: tuple[BumpRow, ...],
333 repos: tuple[str, ...],
334 loops: tuple[Loop, ...],
335 policy: AutonomyPolicy,
336 environment: str,
337) -> tuple[ClassGate, ...]:
338 """The earned-autonomy standing of each (repo, loop) class.
339 
340 Counts only PRs *decided within the window* — trust is recent, not lifetime
341 (§2.11) — *and* earned under the current ``environment`` (§3.7's conditional
342 property): a PR opened under a different judge model no longer counts, so a
343 model swap resets the class. ``prior_env_decided`` keeps that reset legible.
344 The budget figures (approvals / reclaim per week) translate the record into
345 the steward-time MHE meters (§3.6): what the class costs now, and what
346 moving its gate would hand back.
347 """
348 weeks = max(policy.window_days / 7.0, 1.0)
349 gates: list[ClassGate] = []
350 for loop in loops:
351 for repo in repos:
352 in_window = [
353 r
354 for r in rows
355 if r.repo == repo
356 and r.loop == loop.value
357 and r.state in ("merged", "closed")
358 and _within(_decided_at(r), now, policy.window_days)
359 ]
360 # An empty ``environment`` means "don't filter" (none configured);
361 # otherwise only PRs stamped with the current one count.
362 decided_rows = [
363 r for r in in_window if not environment or r.env == environment
364 ]
365 prior_env_decided = len(in_window) - len(decided_rows)
366 merged_rows = [r for r in decided_rows if r.state == "merged"]
367 decided = len(decided_rows)
368 merged = len(merged_rows)
369 # The post-merge defect bearing, per class (§3.8): confirmed-held
370 # outcomes and how many of those went bad. Independent of the rate.
371 determined = sum(
372 1
373 for r in merged_rows
374 if r.post_merge in ("held", "broke", "reverted")
375 )
376 defects = sum(
377 1 for r in merged_rows if r.post_merge in ("broke", "reverted")
378 )
379 earned, blocker = class_earned(
380 decided=decided,
381 merged=merged,
382 determined=determined,
383 defects=defects,
384 policy=policy,
385 )
386 # Reclaim is the budget a gate *move* hands back — so it is zero
387 # until the class has actually earned the move. Counting the
388 # clean-and-green merges of an un-earned class would imply savings
389 # the gate would refuse (every PR stays held at "class not earned").
390 reclaimable = (
391 sum(
392 1
393 for r in merged_rows
394 if r.verdict == "clean" and r.ci == "passed"
395 )
396 if earned
397 else 0
398 )
399 gates.append(
400 ClassGate(
401 repo=repo,
402 loop=loop.value,
403 decided=decided,
404 merged=merged,
405 merge_rate=(merged / decided) if decided else None,
406 determined=determined,
407 defects=defects,
408 defect_rate=(
409 (defects / determined) if determined else None
410 ),
411 prior_env_decided=prior_env_decided,
412 earned=earned,
413 blocker=blocker,
414 approvals_per_week=round(merged / weeks, 2),
415 reclaim_per_week=round(reclaimable / weeks, 2),
416 window_days=policy.window_days,
417 )
418 )
419 return tuple(gates)
420 
421 
422def earned_now(
423 now: datetime,
424 prs: tuple[GithubPr, ...],
425 outcomes: dict[tuple[str, int], str],
426 repo: str,
427 loop: Loop,
428 policy: AutonomyPolicy,
429 environment: str,
430) -> bool:
431 """Whether one (repo, loop) class has earned its gate, from live data.
432 
433 The exact computation the dashboard's class-gate panel shows, exposed so the
434 acting gate (the loop) reuses it rather than duplicating the windowing,
435 environment filter, and triangulation. Pure over already-fetched data.
436 """
437 rows = _bump_rows(now, prs, (), outcomes)
438 gates = _class_gates(now, rows, (repo,), (loop,), policy, environment)
439 return any(g.earned for g in gates)
440 
441 
442def _gate(
443 rows: tuple[BumpRow, ...],
444 gates: tuple[ClassGate, ...],
445 policy: AutonomyPolicy,
446) -> tuple[BumpRow, ...]:
447 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
448 
449 The verdict is the gate's: would this PR auto-merge under its class's grant
450 — the loop's real decision where the repo is allowlisted, advisory (the
451 shadow gate) where it is not. The reason is the grant met, or the first
452 blocker to fix.
453 """
454 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
455 open_rows = [r for r in rows if r.state == "open"]
456 open_rows.sort(
457 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
458 reverse=True,
459 )
460 annotated: list[BumpRow] = []
461 for row in open_rows:
462 earned, blocker = earned_by_class.get(
463 (row.repo, row.loop), (False, "no record for this class")
464 )
465 verdict = pr_autonomy(
466 repo=row.repo,
467 verdict=row.verdict,
468 ci=row.ci,
469 earned=earned,
470 blocker=blocker,
471 policy=policy,
472 )
473 annotated.append(
474 row.model_copy(
475 update={
476 "would_auto_merge": verdict.would_merge,
477 "held_reason": (
478 None if verdict.would_merge else verdict.reason
479 ),
480 }
481 )
482 )
483 return tuple(annotated)
484 
485 
486def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
487 """Bump loops that did not close, newest first."""
488 failures = [
489 Failure(
490 workflow_id=bump.workflow_id,
491 kind=bump.status,
492 reason=bump.reason,
493 when=bump.close,
494 )
495 for bump in bumps
496 if bump.status in _FAILURE_STATUSES
497 ]
498 failures.sort(
499 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
500 reverse=True,
501 )
502 return tuple(failures)
503 
504 
505def _loop_title(loop: Loop) -> str:
506 """The human tab title for a loop key (``dependency-patch`` -> ...)."""
507 return loop.value.capitalize()
508 
509 
510def _failure_loop(workflow_id: str, loops: tuple[Loop, ...]) -> Loop | None:
511 """Which loop a failed bump belongs to, from its workflow id.
512 
513 Non-default loops carry their name as an id segment
514 (``froot-bump-security-patch-…``); dependency-patch is segmentless
515 (``froot-bump-…``), so it is the fallback once the specific loops miss.
516 """
517 for loop in loops:
518 if loop is not Loop.DEPENDENCY_PATCH and workflow_id.startswith(
519 f"froot-bump-{loop.value}-"
520 ):
521 return loop
522 if workflow_id.startswith("froot-bump-"):
523 return next(
524 (loop for loop in loops if loop is Loop.DEPENDENCY_PATCH), None
525 )
526 return None
527 
528 
529def _bump_loops(
530 now: datetime,
531 rows: tuple[BumpRow, ...],
532 canary_rows: tuple[BumpRow, ...],
533 failures: tuple[Failure, ...],
534 scans: tuple[ScanExecution, ...],
535 repos: tuple[str, ...],
536 loops: tuple[Loop, ...],
537 policy: AutonomyPolicy,
538 environment: str,
539 scan_interval_seconds: int,
540 reliability_window_days: int,
541) -> tuple[LoopView, ...]:
542 """One self-contained :class:`LoopView` per loop — the per-loop tabs.
543 
544 Partitions the already-computed bump rows by loop and runs the very same
545 aggregates the combined view uses, so each loop's tab is the whole dashboard
546 scoped to one trust class (§3.9). Failures are attributed by workflow id.
547 """
548 views: list[LoopView] = []
549 for loop in loops:
550 loop_rows = tuple(r for r in rows if r.loop == loop.value)
551 loop_canary = tuple(r for r in canary_rows if r.loop == loop.value)
552 loop_gates = _class_gates(
553 now, loop_rows, repos, (loop,), policy, environment
554 )
555 loop_failures = tuple(
556 f for f in failures if _failure_loop(f.workflow_id, loops) is loop
557 )
558 views.append(
559 LoopView(
560 loop=loop.value,
561 title=_loop_title(loop),
562 icon=registry.get(loop).dashboard_icon,
563 scan_loops=_scan_loops(repos, (loop,), scans),
564 scan_interval_seconds=scan_interval_seconds,
565 track_record=_track_record(loop_rows),
566 class_gates=loop_gates,
567 verification=_verification(loop_rows),
568 reliability=_reliability(loop_rows, reliability_window_days),
569 probes=_probes(loop_canary),
570 judgment=_judgment(loop_rows),
571 gate=_gate(loop_rows, loop_gates, policy),
572 bumps=loop_rows,
573 failures=loop_failures,
574 )
575 )
576 return tuple(views)
577 
578 
579# Which per-repo-loop-id namer each advisory loop uses. The naming is bespoke
580# (the loops' workflows are still their own), so the join keeps a tiny dispatch;
581# the per-PR prefix is then a uniform transform of the loop id (below).
582_ADVISORY_LOOP_ID = {
583 Loop.DETERMINISM_REVIEW: review_workflow_id,
584 Loop.A11Y_REVIEW: a11y_review_workflow_id,
586 
587 
588def _advisory_loop_id(loop: Loop, repo: str) -> str | None:
589 """The deterministic per-repo loop id for ``repo``, if valid and known."""
590 namer = _ADVISORY_LOOP_ID.get(loop)
591 if namer is None:
592 return None
593 match RepoRef.parse(repo):
594 case Ok(ref):
595 return namer(TargetRepo(repo=ref))
596 case _:
597 return None
598 
599 
600def _pr_advisory_prefix(loop: Loop, repo: str) -> str | None:
601 """The id prefix every per-PR review of ``(loop, repo)`` shares (join key).
602 
603 Each per-PR id is the loop id with ``froot-`` widened to ``froot-pr-``, then
604 the ``-<pr>-<sha>`` tail; the shared prefix is that, sans tail.
605 """
606 loop_id = _advisory_loop_id(loop, repo)
607 if loop_id is None:
608 return None
609 return "froot-pr-" + loop_id.removeprefix("froot-") + "-"
610 
611 
612def _attribute_advisory_repo(
613 loop: Loop, workflow_id: str, repos: tuple[str, ...]
614) -> str | None:
615 """The configured repo a per-PR-review id belongs to (longest prefix)."""
616 best: str | None = None
617 best_len = -1
618 for repo in repos:
619 prefix = _pr_advisory_prefix(loop, repo)
620 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
621 best, best_len = repo, len(prefix)
622 return best
623 
624 
625def _advisory_loops(
626 loop: Loop, repos: tuple[str, ...], execs: tuple[AdvisoryExecution, ...]
627) -> tuple[AdvisoryLoop, ...]:
628 """One liveness row per repo that actually runs this advisory loop.
629 
630 Executions are scoped to the Temporal repos, so a configured repo with no
631 such loop is omitted rather than shown as a dead one.
632 """
633 by_id: dict[str, AdvisoryExecution] = {}
634 for candidate in execs:
635 if candidate.loop != loop:
636 continue
637 current = by_id.get(candidate.workflow_id)
638 if current is None or _newer(candidate.start, current.start):
639 by_id[candidate.workflow_id] = candidate
640 loops: list[AdvisoryLoop] = []
641 for repo in repos:
642 loop_id = _advisory_loop_id(loop, repo)
643 execution = by_id.get(loop_id) if loop_id is not None else None
644 if execution is None:
645 continue
646 loops.append(
647 AdvisoryLoop(
648 repo=repo,
649 status=execution.status,
650 live=execution.status in _LIVE_STATUSES,
651 last_tick=execution.start,
652 )
653 )
654 return tuple(loops)
655 
656 
657def _advisory_rows(
658 loop: Loop,
659 pr_execs: tuple[PrAdvisoryExecution, ...],
660 repos: tuple[str, ...],
661) -> tuple[AdvisoryRow, ...]:
662 """Project each per-PR review of this loop into a row, newest first."""
663 rows: list[AdvisoryRow] = []
664 for execution in pr_execs:
665 if execution.loop != loop:
666 continue
667 repo = _attribute_advisory_repo(loop, execution.workflow_id, repos)
668 pr_url = (
669 f"https://github.com/{repo}/pull/{execution.pr_number}"
670 if repo is not None and execution.pr_number is not None
671 else None
672 )
673 rows.append(
674 AdvisoryRow(
675 repo=repo or "?",
676 pr_number=execution.pr_number,
677 pr_url=pr_url,
678 head_sha=execution.head_sha,
679 findings=execution.findings,
680 detail=execution.detail,
681 comment_url=execution.comment_url,
682 status=execution.status,
683 reviewed_at=execution.close or execution.start,
684 )
685 )
686 rows.sort(
687 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
688 reverse=True,
689 )
690 return tuple(rows)
691 
692 
693def _advisory_record(
694 loops: tuple[AdvisoryLoop, ...], rows: tuple[AdvisoryRow, ...]
695) -> AdvisoryRecord:
696 """Counts over this loop's completed reviews."""
697 completed = [r for r in rows if r.status == "completed"]
698 flagged = sum(1 for r in completed if r.findings > 0)
699 findings = sum(r.findings for r in completed)
700 return AdvisoryRecord(
701 reviewed=len(completed),
702 flagged=flagged,
703 clean=len(completed) - flagged,
704 findings=findings,
705 repos_covered=len(loops),
706 )
707 
708 
709def _advisory_views(
710 repos: tuple[str, ...],
711 execs: tuple[AdvisoryExecution, ...],
712 pr_execs: tuple[PrAdvisoryExecution, ...],
713 intervals: dict[Loop, int],
714) -> tuple[AdvisoryView, ...]:
715 """One tab per registered advisory loop, presentation from the registry.
716 
717 The emit-signal specs drive both which tabs exist and how each is labelled
718 (icon, title), so registering an advisory loop is all it takes to surface
719 its tab — nothing here changes.
720 """
721 views: list[AdvisoryView] = []
722 for spec in registry.all_specs():
723 tail = spec.tail
724 if not isinstance(tail, registry.AdvisoryTail):
725 continue
726 loop = spec.loop
727 loops = _advisory_loops(loop, repos, execs)
728 rows = _advisory_rows(loop, pr_execs, repos)
729 views.append(
730 AdvisoryView(
731 loop=loop,
732 icon=spec.dashboard_icon,
733 title=tail.panel_title,
734 interval_seconds=intervals.get(loop, 0),
735 loops=loops,
736 record=_advisory_record(loops, rows),
737 rows=rows,
738 )
739 )
740 return tuple(views)
741 
742 
743def _sources(
744 github_error: str | None,
745 github_count: int,
746 temporal_error: str | None,
747 temporal_count: int,
748 telemetry: RunTelemetry,
749 clickhouse_error: str | None,
750) -> tuple[SourceHealth, ...]:
751 """Per-source health for the header strip."""
752 clickhouse_ok = clickhouse_error is None
753 if clickhouse_error == "off":
754 clickhouse_detail = "off"
755 elif clickhouse_error is not None:
756 clickhouse_detail = clickhouse_error
757 else:
758 spans = telemetry.total_spans
759 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
760 return (
761 SourceHealth(
762 name="github",
763 ok=github_error is None,
764 detail=github_error or f"{github_count} PRs",
765 ),
766 SourceHealth(
767 name="temporal",
768 ok=temporal_error is None,
769 detail=temporal_error or f"{temporal_count} workflows",
770 ),
771 SourceHealth(
772 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
773 ),
774 )
775 
776 
777def assemble(
778 *,
779 now: datetime,
780 repos: tuple[str, ...],
781 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
782 policy: AutonomyPolicy = _DEFAULT_POLICY,
783 scan_interval_seconds: int,
784 advisory_intervals: dict[Loop, int] | None = None,
785 github: tuple[tuple[GithubPr, ...], str | None],
786 temporal: tuple[
⋯ 75 lines hidden (lines 787–861)
787 tuple[
788 tuple[ScanExecution, ...],
789 tuple[BumpExecution, ...],
790 tuple[AdvisoryExecution, ...],
791 tuple[PrAdvisoryExecution, ...],
792 ],
793 str | None,
794 ],
795 telemetry: tuple[RunTelemetry, str | None],
796 outcomes: dict[tuple[str, int], str] | None = None,
797 reliability_window_days: int = 90,
798 environment: str = "",
799) -> DashboardModel:
800 """Build the whole view from the readers' ``(data, error)`` outputs.
801 
802 ``outcomes`` is the post-merge signal per ``(repo, number)`` from the
803 outcome reader (best-effort); absent for a merge older than the window.
804 """
805 prs, github_error = github
806 (
807 (scans, bumps, advisory, pr_advisory),
808 temporal_error,
809 ) = temporal
810 run_telemetry, clickhouse_error = telemetry
811 
812 all_rows = _bump_rows(now, prs, bumps, outcomes or {})
813 # Canary probes are synthetic bad bumps — keep them out of every genuine
814 # bearing (track record, defect rate, gates) so a planted failure can never
815 # pollute the real reputation; they get their own tally.
816 canary_rows = tuple(r for r in all_rows if is_canary(r.to_version))
817 rows = tuple(r for r in all_rows if not is_canary(r.to_version))
818 class_gates = _class_gates(now, rows, repos, loops, policy, environment)
819 all_failures = _failures(bumps)
820 bump_loops = _bump_loops(
821 now,
822 rows,
823 canary_rows,
824 all_failures,
825 scans,
826 repos,
827 loops,
828 policy,
829 environment,
830 scan_interval_seconds,
831 reliability_window_days,
832 )
833 advisory_views = _advisory_views(
834 repos, advisory, pr_advisory, advisory_intervals or {}
835 )
836 return DashboardModel(
837 generated_at=now,
838 repos_configured=repos,
839 scan_interval_seconds=scan_interval_seconds,
840 sources=_sources(
841 github_error,
842 len(prs),
843 temporal_error,
844 len(scans) + len(bumps) + len(advisory) + len(pr_advisory),
845 run_telemetry,
846 clickhouse_error,
847 ),
848 scan_loops=_scan_loops(repos, loops, scans),
849 track_record=_track_record(rows),
850 class_gates=class_gates,
851 verification=_verification(rows),
852 reliability=_reliability(rows, reliability_window_days),
853 probes=_probes(canary_rows),
854 judgment=_judgment(rows),
855 gate=_gate(rows, class_gates, policy),
856 bumps=rows,
857 failures=all_failures,
858 advisory=advisory_views,
859 telemetry=run_telemetry,
860 bump_loops=bump_loops,
861 )

The discriminating tests

The render test that only the new layer can pass: a tab's icon flows from its spec, so changing the spec's icon changes the rendered tab with no renderer edit (the advisory mirror of the acting family's spec-driven test).

tests/test_dashboard_render.py · 546 lines
tests/test_dashboard_render.py546 lines · Python
⋯ 309 lines hidden (lines 1–309)
1from __future__ import annotations
2 
3from collections.abc import Sequence
4from datetime import UTC, datetime
5 
6from froot.dashboard import read_model, render
7from froot.dashboard.github_source import GithubPr
8from froot.dashboard.model import ActivityStat, DashboardModel, RunTelemetry
9from froot.dashboard.temporal_source import (
10 AdvisoryExecution,
11 BumpExecution,
12 PrAdvisoryExecution,
13 ScanExecution,
15from froot.domain.loop import Loop
16from froot.policy.autonomy import AutonomyPolicy
17 
18NOW = datetime(2026, 6, 3, 12, 0, tzinfo=UTC)
19REPO = "mseeks/revisionist"
20 
21 
22def _model(
23 prs: Sequence[GithubPr] = (),
24 scans: Sequence[ScanExecution] = (),
25 telemetry: tuple[RunTelemetry, str | None] | None = None,
26 advisory: Sequence[AdvisoryExecution] = (),
27 pr_advisory: Sequence[PrAdvisoryExecution] = (),
28) -> DashboardModel:
29 if telemetry is None:
30 telemetry = (
31 RunTelemetry(
32 available=False,
33 total_spans=0,
34 error_spans=0,
35 last_activity=None,
36 window_days=3,
37 activities=(),
38 ),
39 "off",
40 )
41 return read_model.assemble(
42 now=NOW,
43 repos=(REPO,),
44 scan_interval_seconds=86_400,
45 advisory_intervals={
46 Loop.DETERMINISM_REVIEW: 300,
47 Loop.A11Y_REVIEW: 300,
48 },
49 github=(tuple(prs), None),
50 temporal=(
51 (tuple(scans), (), tuple(advisory), tuple(pr_advisory)),
52 None,
53 ),
54 telemetry=telemetry,
55 )
56 
57 
58def _pr(number: int, package: str, state: str, **kw) -> GithubPr:
59 return GithubPr(
60 repo=REPO,
61 number=number,
62 url=f"https://github.com/{REPO}/pull/{number}",
63 package=package,
64 from_version=kw.get("from_version"),
65 to_version=kw.get("to_version", "1.0.0"),
66 verdict=kw.get("verdict"),
67 state=state,
68 opened_at=kw.get("opened", NOW),
69 merged_at=kw.get("merged"),
70 )
71 
72 
73# ── the page shell: self-contained, no JS, tabbed ────────────────────────────
74def test_page_is_a_self_contained_html_document():
75 html = render.page(_model())
76 assert html.startswith("<!doctype html>")
77 assert html.rstrip().endswith("</html>")
78 assert "http://" not in html and "https://" not in html # no links
79 assert "<script src" not in html.lower() # no external JS; theme is inline
80 
81 
82def test_page_is_tabbed_one_per_loop_plus_determinism_and_telemetry():
83 html = render.page(_model())
84 assert '<nav class="tabbar">' in html
85 # CSS-only tabs: hidden radio inputs drive the panels (no external JS).
86 assert 'type="radio"' in html and "<script src" not in html.lower()
87 for label in (
88 "Dependency-patch",
89 "Determinism review",
90 "A11y review",
91 "Telemetry",
92 ):
93 assert label in html
94 # the footer's authority envelope, trimmed of the word-bomb
95 assert "Authority envelope" in html
96 assert "froot" in html
97 
98 
99def test_dead_code_loop_renders_with_scissors_and_unused():
100 # The dead-code tab gets the scissors icon and its removal rows read
101 # "<pkg> -> unused", not the bump-shaped "<pkg> -> <version>".
102 rm = GithubPr(
103 repo=REPO,
104 number=5,
105 url=f"https://github.com/{REPO}/pull/5",
106 package="left-pad",
107 from_version=None,
108 to_version=None,
109 verdict=None,
110 state="open",
111 opened_at=NOW,
112 merged_at=None,
113 loop="dead-code",
114 )
115 model = read_model.assemble(
116 now=NOW,
117 repos=(REPO,),
118 loops=(Loop.DEAD_CODE,),
119 scan_interval_seconds=86_400,
120 advisory_intervals={
121 Loop.DETERMINISM_REVIEW: 300,
122 Loop.A11Y_REVIEW: 300,
123 },
124 github=((rm,), None),
125 temporal=(((), (), (), ()), None),
126 telemetry=(
127 RunTelemetry(
128 available=False,
129 total_spans=0,
130 error_spans=0,
131 last_activity=None,
132 window_days=3,
133 activities=(),
134 ),
135 "off",
136 ),
137 )
138 html = render.page(model)
139 assert "Dead-code" in html # the tab label
140 assert "M20 4 8.12 15.88" in html # the scissors icon path
141 assert "unused" in html # the removal's target column, not a version
142 
143 
144def test_loop_tab_icon_is_spec_driven_not_a_hardcoded_map() -> None:
145 # The dashboard-from-registry milestone: a loop's tab presentation flows
146 # from its registered spec, so changing the spec's icon changes the rendered
147 # tab with NO renderer edit (the renderer no longer keys icons by loop).
148 from dataclasses import replace
149 
150 from froot.dashboard.render import _ICONS
151 from froot.loops import registry
152 
153 original = registry.get(Loop.DEPENDENCY_PATCH)
154 assert original.dashboard_icon == "package"
155 try:
156 registry.register(replace(original, dashboard_icon="layers"))
157 html = render.page(_model())
158 assert _ICONS["layers"] in html # the spec's new icon is rendered
159 assert _ICONS["package"] not in html # the old one is gone
160 finally:
161 registry.register(original) # restore for the other tests
162 
163 
164def test_theme_toggle_is_present_and_light_is_the_default():
165 html = render.page(_model())
166 # Light is the :root default; dark applies via system pref or the toggle.
167 assert "--fg:#1b1f24" in html # the light palette is the base
168 assert "[data-theme=dark]" in html # the forced-dark override exists
169 assert "__toggleTheme" in html and 'class="themetgl"' in html
170 
171 
172def test_gate_hero_shows_the_four_bearings_and_a_decision():
173 html = render.page(_model())
174 assert "Earned autonomy" in html and "the gate" in html
175 for bearing in ("approval rate", "defect rate", "probe", "deep review"):
176 assert bearing in html
177 assert "HOLD" in html # no record yet -> the gate holds
178 
179 
180def test_page_renders_track_record_numbers():
181 prs = [
182 _pr(1, "a", "merged", opened=NOW, merged=NOW),
183 _pr(2, "b", "merged", opened=NOW, merged=NOW),
184 ]
185 html = render.page(_model(prs=prs))
186 assert "100%" in html # 2/2 approval rate
187 assert ">2<" in html # the proposed count as a card stat
188 assert "2 merged" in html
189 
190 
191def test_page_links_open_prs_and_lists_them_in_the_queue():
192 prs = [_pr(23, "vitest", "open", to_version="3.2.6")]
193 html = render.page(_model(prs=prs))
194 assert f"https://github.com/{REPO}/pull/23" in html
195 assert "vitest" in html
196 assert "#23" in html
197 
198 
199def test_page_escapes_dynamic_content():
200 evil = "<script>alert(1)</script>"
201 prs = [_pr(1, evil, "merged", opened=NOW, merged=NOW)]
202 html = render.page(_model(prs=prs))
203 assert "<script>alert(1)</script>" not in html
204 assert "&lt;script&gt;" in html
205 
206 
207def test_empty_queue_is_explicit_not_blank():
208 html = render.page(_model())
209 assert "Nothing awaiting you" in html
210 
211 
212# ── telemetry tab ────────────────────────────────────────────────────────────
213def test_telemetry_panel_reports_unavailable_when_off():
214 html = render.page(_model())
215 assert "Unavailable" in html
216 
217 
218def test_telemetry_panel_renders_activity_rows_when_available():
219 telemetry = (
220 RunTelemetry(
221 available=True,
222 total_spans=75,
223 error_spans=10,
224 last_activity=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
225 window_days=3,
226 activities=(
227 ActivityStat(
228 name="open_pull_request",
229 count=14,
230 avg_ms=14162.0,
231 max_ms=55828.0,
232 ),
233 ),
234 ),
235 None,
236 )
237 html = render.page(_model(telemetry=telemetry))
238 assert "open_pull_request" in html
239 assert ">75<" in html and "spans" in html
240 
241 
242def test_scan_cadence_shows_liveness_and_next_due():
243 scans = [
244 ScanExecution(
245 workflow_id="froot-scan-mseeks-revisionist",
246 status="running",
247 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
248 )
249 ]
250 html = render.page(_model(scans=scans))
251 assert REPO in html # the class row carries the repo
252 assert "next" in html # the next-due hint for the live loop
253 
254 
255# ── the advisory family tabs (determinism-review, a11y-review) ───────────────
256# Both advisory loops render through one generic, registry-driven tab. These
257# exercise that tab via the determinism loop, plus the a11y loop where the two
258# must stay distinct (no cross-attribution through the shared renderer).
259def _adv_loop(
260 loop: Loop, slug: str, status: str = "running"
261) -> AdvisoryExecution:
262 return AdvisoryExecution(
263 loop=loop,
264 workflow_id=f"froot-{slug}-mseeks-revisionist",
265 status=status,
266 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
267 )
268 
269 
270def _pr_adv(
271 loop: Loop,
272 slug: str,
273 pr: int,
274 findings: int,
275 detail: tuple[str, ...],
276 comment: str | None = None,
277) -> PrAdvisoryExecution:
278 return PrAdvisoryExecution(
279 loop=loop,
280 workflow_id=f"froot-pr-{slug}-mseeks-revisionist-{pr}-abc1234def56",
281 status="completed",
282 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
283 close=datetime(2026, 6, 3, 6, 1, tzinfo=UTC),
284 pr_number=pr,
285 head_sha="abc1234def56",
286 findings=findings,
287 detail=detail,
288 comment_url=comment,
289 )
290 
291 
292def _review(status: str = "running") -> AdvisoryExecution:
293 return _adv_loop(Loop.DETERMINISM_REVIEW, "review", status)
294 
295 
296def _pr_review(
297 pr: int, findings: int, detail: tuple[str, ...], comment: str | None = None
298) -> PrAdvisoryExecution:
299 return _pr_adv(
300 Loop.DETERMINISM_REVIEW, "review", pr, findings, detail, comment
301 )
302 
303 
304def _pr_a11y(
305 pr: int, findings: int, detail: tuple[str, ...], comment: str | None = None
306) -> PrAdvisoryExecution:
307 return _pr_adv(Loop.A11Y_REVIEW, "a11y", pr, findings, detail, comment)
308 
309 
310def test_advisory_tabs_are_registry_driven_one_per_emit_signal_loop():
311 # Both advisory loops surface as their own tab, titled from the registered
312 # spec — the dashboard derives the *set* of advisory tabs from the
313 # registry, never a hard-coded determinism+a11y pair.
314 html = render.page(_model())
315 assert "Determinism review" in html # determinism-review's panel_title
316 assert "A11y review" in html # a11y-review's panel_title
317 assert "No PRs reviewed yet" in html
318 
319 
320def test_advisory_tab_presentation_is_spec_driven_not_hardcoded() -> None:
321 # The cut-#2 milestone for the advisory family: a tab's icon flows from its
322 # spec, so changing the spec's icon changes the rendered tab with NO
323 # renderer edit (mirroring the acting family's spec-driven tabs).
324 from dataclasses import replace
325 
326 from froot.dashboard.render import _ICONS
327 from froot.loops import registry
328 
329 original = registry.get(Loop.A11Y_REVIEW)
330 assert original.dashboard_icon == "accessibility"
331 try:
332 registry.register(replace(original, dashboard_icon="layers"))
333 html = render.page(_model())
334 assert _ICONS["layers"] in html # the spec's new icon is rendered
335 finally:
336 registry.register(original) # restore for the other tests
⋯ 210 lines hidden (lines 337–546)
337 
338 
339def test_advisory_tab_counts_a_live_loop():
340 html = render.page(_model(advisory=[_review("running")]))
341 assert "loops live" in html # the liveness card
342 assert ">1<" in html # one repo covered / one loop live
343 
344 
345def test_flagged_advisory_renders_finding_count_and_comment_link():
346 comment = f"https://github.com/{REPO}/pull/7#issuecomment-1"
347 pr_advisory = [
348 _pr_review(7, 1, ("datetime.datetime.now",), comment=comment)
349 ]
350 html = render.page(_model(pr_advisory=pr_advisory))
351 assert "datetime.datetime.now" in html # the kind, in the detail column
352 assert "1 finding" in html
353 assert "#7" in html
354 assert comment in html # the one-click comment link
355 
356 
357def test_clean_advisory_renders_clean_not_a_finding():
358 html = render.page(_model(pr_advisory=[_pr_review(8, 0, ())]))
359 assert ">clean<" in html
360 
361 
362def test_a11y_loop_does_not_cross_attribute_to_determinism():
363 # The two advisory loops share one renderer but a per-PR a11y review must
364 # land on the a11y tab with its own gap kind, not the determinism tab.
365 html = render.page(_model(pr_advisory=[_pr_a11y(7, 1, ("missing-alt",))]))
366 assert "missing-alt" in html # only the a11y loop surfaces this kind
367 assert "1 finding" in html
368 
369 
370# ── the gate: per-class standing + queue badge ───────────────────────────────
371def _model_p(
372 prs: Sequence[GithubPr],
373 bumps: Sequence[BumpExecution],
374 policy: AutonomyPolicy,
375 outcomes: dict[tuple[str, int], str] | None = None,
376) -> DashboardModel:
377 telemetry = (
378 RunTelemetry(
379 available=False,
380 total_spans=0,
381 error_spans=0,
382 last_activity=None,
383 window_days=3,
384 activities=(),
385 ),
386 "off",
387 )
388 return read_model.assemble(
389 now=NOW,
390 repos=(REPO,),
391 policy=policy,
392 scan_interval_seconds=86_400,
393 advisory_intervals={
394 Loop.DETERMINISM_REVIEW: 300,
395 Loop.A11Y_REVIEW: 300,
396 },
397 github=(tuple(prs), None),
398 temporal=(((), tuple(bumps), (), ()), None),
399 telemetry=telemetry,
400 outcomes=outcomes,
401 )
402 
403 
404def _clean_green(number: int, package: str) -> tuple[GithubPr, BumpExecution]:
405 opened = datetime(2026, 5, 20, 12, 0, tzinfo=UTC)
406 merged = datetime(2026, 5, 20, 12, 15, tzinfo=UTC)
407 pr = _pr(
408 number, package, "merged", verdict="clean", opened=opened, merged=merged
409 )
410 bump = BumpExecution(
411 workflow_id=f"froot-bump-mseeks-revisionist-{package}",
412 status="completed",
413 start=opened,
414 close=merged,
415 verdict="clean",
416 ci="passed",
417 pr_number=number,
418 repo=REPO,
419 reason=None,
420 )
421 return pr, bump
422 
423 
424def test_class_table_shows_the_unearned_blocker_with_no_history():
425 html = render.page(_model())
426 assert "Per-class standing" in html
427 assert "only 0/5 decided recently" in html # the honest blocker
428 
429 
430def test_hero_says_no_classes_when_no_repos_configured():
431 model = read_model.assemble(
432 now=NOW,
433 repos=(),
434 scan_interval_seconds=86_400,
435 advisory_intervals={
436 Loop.DETERMINISM_REVIEW: 300,
437 Loop.A11Y_REVIEW: 300,
438 },
439 github=((), None),
440 temporal=(((), (), (), ()), None),
441 telemetry=(
442 RunTelemetry(
443 available=False,
444 total_spans=0,
445 error_spans=0,
446 last_activity=None,
447 window_days=3,
448 activities=(),
449 ),
450 "off",
451 ),
452 )
453 assert "No classes yet" in render.page(model)
454 
455 
456def test_queue_badge_holds_open_pr_with_substantive_reason():
457 prs = [_pr(23, "vitest", "open", verdict="clean", opened=NOW)]
458 html = render.page(_model_p(prs, [], AutonomyPolicy()))
459 assert "held" in html
460 assert "class not earned" in html
461 
462 
463def test_class_earned_pill_and_budget():
464 pairs = [_clean_green(n, f"pkg{n}") for n in (1, 2, 3, 4, 5)]
465 prs = [p for p, _ in pairs]
466 bumps = [b for _, b in pairs]
467 policy = AutonomyPolicy(min_decided=3, allowlisted_repos=frozenset({REPO}))
468 held = {(REPO, n): "held" for n in (1, 2, 3, 4, 5)} # defect bearing clean
469 html = render.page(_model_p(prs, bumps, policy, outcomes=held))
470 assert ">earned<" in html # the class cleared its gate (the pill)
471 assert "budget/wk" in html # the budget column
472 
473 
474def test_queue_badge_would_auto_merge_on_earned_allowlisted_class():
475 pairs = [_clean_green(n, f"pkg{n}") for n in (1, 2, 3)]
476 prs = [p for p, _ in pairs]
477 bumps = [b for _, b in pairs]
478 _, open_bump = _clean_green(9, "axios")
479 prs.append(_pr(9, "axios", "open", verdict="clean", opened=NOW))
480 bumps.append(open_bump)
481 policy = AutonomyPolicy(min_decided=3, allowlisted_repos=frozenset({REPO}))
482 held = {(REPO, n): "held" for n in (1, 2, 3)} # clear the defect bearing
483 html = render.page(_model_p(prs, bumps, policy, outcomes=held))
484 assert "would auto-merge" in html
485 
486 
487# ── reliability + probes surface in the loop's cards / detail ────────────────
488def _model_outcomes(
489 prs: Sequence[GithubPr], outcomes: dict[tuple[str, int], str]
490) -> DashboardModel:
491 telemetry = (
492 RunTelemetry(
493 available=False,
494 total_spans=0,
495 error_spans=0,
496 last_activity=None,
497 window_days=3,
498 activities=(),
499 ),
500 "off",
501 )
502 return read_model.assemble(
503 now=NOW,
504 repos=(REPO,),
505 scan_interval_seconds=86_400,
506 advisory_intervals={
507 Loop.DETERMINISM_REVIEW: 300,
508 Loop.A11Y_REVIEW: 300,
509 },
510 github=(tuple(prs), None),
511 temporal=(((), (), (), ()), None),
512 telemetry=telemetry,
513 outcomes=outcomes,
514 reliability_window_days=90,
515 )
516 
517 
518def test_defect_rate_card_and_post_merge_tags():
519 prs = [
520 _pr(1, "a", "merged", opened=NOW, merged=NOW),
521 _pr(2, "b", "merged", opened=NOW, merged=NOW),
522 ]
523 outcomes = {(REPO, 1): "held", (REPO, 2): "broke"}
524 html = render.page(_model_outcomes(prs, outcomes))
525 assert "defect rate" in html
526 assert "50%" in html # 1 of 2 determined was a defect
527 assert ">held<" in html and ">broke<" in html # the post-merge tags
528 
529 
530def test_canary_escape_shows_in_probes_card_and_segregated():
531 # A merged canary (to 99.99.99) is a guardrail hole: the probes card counts
532 # the escape, and the canary stays out of the genuine bumps.
533 prs = [
534 _pr(7, "evil", "merged", to_version="99.99.99", opened=NOW, merged=NOW)
535 ]
536 html = render.page(_model_outcomes(prs, {}))
537 assert "probes escaped" in html
538 # the canary is not a real bump -> the track record stays at zero proposed
539 assert "evil" not in html
540 
541 
542def test_bumps_fold_lists_bumps_with_post_merge_column():
543 prs = [_pr(1, "a", "merged", opened=NOW, merged=NOW)]
544 html = render.page(_model_outcomes(prs, {(REPO, 1): "reverted"}))
545 assert "post-merge" in html # the bumps-table column
546 assert "reverted" in html # the per-row post-merge tag

And the read-model test that guards the one real hazard of sharing a code path: the two loops, partitioned by their loop tag, never cross-attribute — each view carries only its own rows and findings.

tests/test_dashboard_read_model.py · 1014 lines
tests/test_dashboard_read_model.py1014 lines · Python
⋯ 513 lines hidden (lines 1–513)
1from __future__ import annotations
2 
3from datetime import UTC, datetime
4 
5from froot.dashboard import read_model
6from froot.dashboard.github_source import GithubPr
7from froot.dashboard.model import AdvisoryView, DashboardModel, RunTelemetry
8from froot.dashboard.temporal_source import (
9 AdvisoryExecution,
10 BumpExecution,
11 PrAdvisoryExecution,
12 ScanExecution,
14from froot.domain.loop import Loop
15from froot.policy.autonomy import AutonomyPolicy
16 
17NOW = datetime(2026, 6, 3, 12, 0, tzinfo=UTC)
18REPO = "mseeks/revisionist"
19 
20 
21def _pr(
22 number: int,
23 package: str,
24 state: str,
25 *,
26 to_version: str | None = "1.0.0",
27 from_version: str | None = None,
28 verdict: str | None = None,
29 opened: datetime | None = None,
30 merged: datetime | None = None,
31 env: str | None = None,
32 loop: str = "dependency-patch",
33) -> GithubPr:
34 return GithubPr(
35 repo=REPO,
36 number=number,
37 url=f"https://github.com/{REPO}/pull/{number}",
38 package=package,
39 from_version=from_version,
40 to_version=to_version,
41 verdict=verdict,
42 state=state,
43 opened_at=opened,
44 merged_at=merged,
45 env=env,
46 loop=loop,
47 )
48 
49 
50def _bump(
51 suffix: str,
52 status: str,
53 *,
54 pr_number: int | None = None,
55 repo: str | None = REPO,
56 verdict: str | None = None,
57 ci: str | None = None,
58 reason: str | None = None,
59 close: datetime | None = None,
60) -> BumpExecution:
61 return BumpExecution(
62 workflow_id=f"froot-bump-mseeks-revisionist-{suffix}",
63 status=status,
64 start=datetime(2026, 6, 2, 19, 45, tzinfo=UTC),
65 close=close,
66 verdict=verdict,
67 ci=ci,
68 pr_number=pr_number,
69 repo=repo,
70 reason=reason,
71 )
72 
73 
74def _telemetry_off() -> tuple[RunTelemetry, str | None]:
75 empty = RunTelemetry(
76 available=False,
77 total_spans=0,
78 error_spans=0,
79 last_activity=None,
80 window_days=3,
81 activities=(),
82 )
83 return empty, "off"
84 
85 
86def _assemble(
87 prs: list[GithubPr],
88 scans: list[ScanExecution],
89 bumps: list[BumpExecution],
90 advisory: list[AdvisoryExecution] | None = None,
91 pr_advisory: list[PrAdvisoryExecution] | None = None,
92) -> DashboardModel:
93 return read_model.assemble(
94 now=NOW,
95 repos=(REPO,),
96 scan_interval_seconds=86_400,
97 advisory_intervals={
98 Loop.DETERMINISM_REVIEW: 300,
99 Loop.A11Y_REVIEW: 300,
100 },
101 github=(tuple(prs), None),
102 temporal=(
103 (
104 tuple(scans),
105 tuple(bumps),
106 tuple(advisory or ()),
107 tuple(pr_advisory or ()),
108 ),
109 None,
110 ),
111 telemetry=_telemetry_off(),
112 )
113 
114 
115def _rich_model() -> DashboardModel:
116 opened1 = datetime(2026, 6, 2, 19, 45, tzinfo=UTC)
117 opened2 = datetime(2026, 6, 2, 19, 46, tzinfo=UTC)
118 opened3 = datetime(2026, 6, 2, 19, 47, tzinfo=UTC)
119 merged_at = datetime(2026, 6, 2, 20, 0, tzinfo=UTC)
120 prs = [
121 _pr(21, "@nuxt/test-utils", "merged", opened=opened1, merged=merged_at),
122 _pr(22, "nuxt", "merged", opened=opened2, merged=merged_at),
123 _pr(23, "vitest", "open", opened=opened3),
124 ]
125 scans = [
126 ScanExecution(
127 workflow_id="froot-scan-mseeks-revisionist",
128 status="running",
129 start=opened1,
130 )
131 ]
132 bumps = [
133 _bump(
134 "nuxt-test-utils-3.19.2",
135 "completed",
136 pr_number=21,
137 verdict="clean",
138 ci="absent",
139 ),
140 _bump(
141 "nuxt-3.21.7",
142 "completed",
143 pr_number=22,
144 verdict="risky",
145 ci="passed",
146 ),
147 _bump(
148 "broken-1.0.0",
149 "terminated",
150 reason="ERESOLVE",
151 close=datetime(2026, 6, 2, 19, 50, tzinfo=UTC),
152 ),
153 ]
154 return _assemble(prs, scans, bumps)
155 
156 
157def test_track_record_counts_and_merge_rate():
158 model = _rich_model()
159 t = model.track_record
160 assert t.opened == 3
161 assert t.merged == 2
162 assert t.open_now == 1
163 assert t.closed_unmerged == 0
164 assert t.merge_rate == 1.0 # 2 merged / 2 decided
165 # ttm: pr21 = 15min, pr22 = 14min -> median 14.5
166 assert t.median_ttm_minutes == 14.5
167 
168 
169def test_verification_keeps_absent_distinct_from_failure():
170 v = _rich_model().verification
171 assert v.passed == 1 # pr22
172 assert v.absent == 1 # pr21
173 assert v.failed == 0
174 assert v.unknown == 1 # pr23 open, no temporal outcome
175 assert v.with_reading == 2
176 assert v.oracle_existed == 1
177 
178 
179def test_judgment_mix_and_calibration():
180 j = _rich_model().judgment
181 assert (j.clean, j.risky, j.unknown, j.none) == (1, 1, 0, 1)
182 assert j.flagged_but_passed == 1 # pr22 risky verdict, CI passed
183 assert j.clean_but_failed == 0
184 
185 
186def test_gate_holds_only_open_prs():
187 gate = _rich_model().gate
188 assert [row.pr_number for row in gate] == [23]
189 assert gate[0].age_hours is not None
190 
191 
192def test_failures_surface_terminated_bumps():
193 failures = _rich_model().failures
194 assert len(failures) == 1
195 assert failures[0].kind == "terminated"
196 assert failures[0].reason == "ERESOLVE"
197 
198 
199def test_scan_loop_is_live_when_a_running_execution_exists():
200 loops = _rich_model().scan_loops
201 assert len(loops) == 1
202 assert loops[0].repo == REPO
203 assert loops[0].live is True
204 assert loops[0].status == "running"
205 
206 
207def test_scan_loop_reports_none_when_no_execution():
208 model = _assemble([], [], [])
209 assert model.scan_loops[0].status == "none"
210 assert model.scan_loops[0].live is False
211 
212 
213def test_scan_loop_not_live_when_terminated():
214 scans = [
215 ScanExecution(
216 workflow_id="froot-scan-mseeks-revisionist",
217 status="terminated",
218 start=NOW,
219 )
220 ]
221 model = _assemble([], scans, [])
222 assert model.scan_loops[0].live is False
223 assert model.scan_loops[0].status == "terminated"
224 
225 
226def test_latest_scan_execution_wins():
227 older = ScanExecution(
228 workflow_id="froot-scan-mseeks-revisionist",
229 status="continued_as_new",
230 start=datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
231 )
232 newer = ScanExecution(
233 workflow_id="froot-scan-mseeks-revisionist",
234 status="running",
235 start=datetime(2026, 6, 3, 0, 0, tzinfo=UTC),
236 )
237 model = _assemble([], [older, newer], [])
238 assert model.scan_loops[0].status == "running"
239 
240 
241def test_verdict_falls_back_to_pr_body_when_temporal_is_gone():
242 # An old PR whose bump aged out of Temporal: verdict comes from the body.
243 prs = [_pr(5, "lodash", "merged", verdict="clean")]
244 model = _assemble(prs, [], [])
245 assert model.bumps[0].verdict == "clean"
246 assert model.bumps[0].ci is None # no durable CI reading
247 
248 
249def test_temporal_outcome_overrides_body_verdict():
250 prs = [_pr(6, "lodash", "merged", verdict="clean")]
251 bumps = [
252 _bump(
253 "lodash-1.0.0",
254 "completed",
255 pr_number=6,
256 verdict="risky",
257 ci="failed",
258 )
259 ]
260 model = _assemble(prs, [], bumps)
261 assert model.bumps[0].verdict == "risky"
262 assert model.bumps[0].ci == "failed"
263 
264 
265def test_median_helper_handles_odd_and_empty():
266 assert read_model._median([3.0, 1.0, 2.0]) == 2.0
267 assert read_model._median([]) is None
268 
269 
270def test_cross_repo_pr_number_does_not_collide():
271 # Temporal lists every repo's bumps; a bump from another repo with the same
272 # PR number must NOT attach its verdict/CI to this repo's PR #1.
273 prs = [_pr(1, "left-pad", "merged", verdict="clean")]
274 other = _bump(
275 "x",
276 "completed",
277 pr_number=1,
278 repo="other/repo",
279 verdict="risky",
280 ci="failed",
281 )
282 model = _assemble(prs, [], [other])
283 row = model.bumps[0]
284 assert row.verdict == "clean" # from the PR body, not the other repo
285 assert row.ci is None # no same-repo Temporal outcome to attach
286 
287 
288def test_same_repo_pr_number_attaches_outcome():
289 prs = [_pr(1, "left-pad", "merged", verdict="clean")]
290 same = _bump(
291 "x", "completed", pr_number=1, repo=REPO, verdict="risky", ci="failed"
292 )
293 model = _assemble(prs, [], [same])
294 assert model.bumps[0].verdict == "risky"
295 assert model.bumps[0].ci == "failed"
296 
297 
298def test_canceled_and_timed_out_appear_in_failures():
299 bumps = [
300 _bump("a", "canceled", close=datetime(2026, 6, 2, 19, 50, tzinfo=UTC)),
301 _bump("b", "timed_out", close=datetime(2026, 6, 2, 19, 51, tzinfo=UTC)),
302 ]
303 model = _assemble([], [], bumps)
304 assert {f.kind for f in model.failures} == {"canceled", "timed_out"}
305 
306 
307def test_source_health_reflects_errors():
308 model = read_model.assemble(
309 now=NOW,
310 repos=(REPO,),
311 scan_interval_seconds=86_400,
312 advisory_intervals={
313 Loop.DETERMINISM_REVIEW: 300,
314 Loop.A11Y_REVIEW: 300,
315 },
316 github=((), "boom"),
317 temporal=(((), (), (), ()), None),
318 telemetry=_telemetry_off(),
319 )
320 health = {s.name: s for s in model.sources}
321 assert health["github"].ok is False
322 assert health["github"].detail == "boom"
323 assert health["temporal"].ok is True
324 assert health["clickhouse"].ok is False # "off"
325 
326 
327# ── The advisory family (determinism-review + a11y-review) ───────────────────
328# Both advisory loops flow through one generic read-model path, partitioned by
329# their loop tag. These exercise it per loop and assert the two never
330# cross-attribute through the shared code.
331def _review(status: str = "running") -> AdvisoryExecution:
332 return AdvisoryExecution(
333 loop=Loop.DETERMINISM_REVIEW,
334 workflow_id="froot-review-mseeks-revisionist",
335 status=status,
336 start=datetime(2026, 6, 3, 11, 55, tzinfo=UTC),
337 )
338 
339 
340def _pr_review(
341 pr: int,
342 *,
343 status: str = "completed",
344 findings: int = 0,
345 detail: tuple[str, ...] = (),
346 head: str = "abc1234def56",
347) -> PrAdvisoryExecution:
348 return PrAdvisoryExecution(
349 loop=Loop.DETERMINISM_REVIEW,
350 workflow_id=f"froot-pr-review-mseeks-revisionist-{pr}-{head}",
351 status=status,
352 start=datetime(2026, 6, 3, 11, 50, tzinfo=UTC),
353 close=datetime(2026, 6, 3, 11, 51, tzinfo=UTC),
354 pr_number=pr,
355 head_sha=head,
356 findings=findings,
357 detail=detail,
358 comment_url=None,
359 )
360 
361 
362def _a11y(status: str = "running") -> AdvisoryExecution:
363 return AdvisoryExecution(
364 loop=Loop.A11Y_REVIEW,
365 workflow_id="froot-a11y-mseeks-revisionist",
366 status=status,
367 start=datetime(2026, 6, 3, 11, 55, tzinfo=UTC),
368 )
369 
370 
371def _pr_a11y(
372 pr: int,
373 *,
374 status: str = "completed",
375 findings: int = 0,
376 detail: tuple[str, ...] = (),
377 head: str = "abc1234def56",
378) -> PrAdvisoryExecution:
379 return PrAdvisoryExecution(
380 loop=Loop.A11Y_REVIEW,
381 workflow_id=f"froot-pr-a11y-mseeks-revisionist-{pr}-{head}",
382 status=status,
383 start=datetime(2026, 6, 3, 11, 50, tzinfo=UTC),
384 close=datetime(2026, 6, 3, 11, 51, tzinfo=UTC),
385 pr_number=pr,
386 head_sha=head,
387 findings=findings,
388 detail=detail,
389 comment_url=None,
390 )
391 
392 
393def _view(model: DashboardModel, loop: Loop) -> AdvisoryView:
394 (view,) = [v for v in model.advisory if v.loop == loop]
395 return view
396 
397 
398def test_advisory_views_one_per_registered_emit_signal_loop():
399 # The advisory tabs are derived from the registry's emit-signal specs, with
400 # each view's presentation (title, icon) read straight from its spec.
401 model = _assemble([], [], [])
402 loops = {v.loop for v in model.advisory}
403 assert Loop.DETERMINISM_REVIEW in loops
404 assert Loop.A11Y_REVIEW in loops
405 det = _view(model, Loop.DETERMINISM_REVIEW)
406 assert det.title == "Determinism review" # from the spec's panel_title
407 assert det.icon == "search" # from the spec's dashboard_icon
408 
409 
410def test_review_loop_live_when_running():
411 model = _assemble([], [], [], advisory=[_review("running")])
412 det = _view(model, Loop.DETERMINISM_REVIEW)
413 assert len(det.loops) == 1
414 assert det.loops[0].repo == REPO
415 assert det.loops[0].live is True
416 
417 
418def test_review_loop_omitted_when_no_execution():
419 # Reviews are scoped to the Temporal repos; a repo with no review loop is
420 # left out, not shown as a dead one (unlike the scan heartbeat).
421 assert _view(_assemble([], [], []), Loop.DETERMINISM_REVIEW).loops == ()
422 
423 
424def test_review_record_counts_findings_clean_and_total():
425 pr_reviews = [
426 _pr_review(
427 1, findings=2, detail=("datetime.datetime.now", "random.random")
428 ),
429 _pr_review(2, findings=0),
430 _pr_review(3, findings=1, detail=("time.time",)),
431 ]
432 model = _assemble([], [], [], advisory=[_review()], pr_advisory=pr_reviews)
433 r = _view(model, Loop.DETERMINISM_REVIEW).record
434 assert (r.reviewed, r.flagged, r.clean, r.findings) == (3, 2, 1, 3)
435 assert r.repos_covered == 1
436 
437 
438def test_review_row_attributes_repo_and_pr_url():
439 model = _assemble(
440 [],
441 [],
442 [],
443 pr_advisory=[_pr_review(7, findings=1, detail=("time.time",))],
444 )
445 row = _view(model, Loop.DETERMINISM_REVIEW).rows[0]
446 assert row.repo == REPO
447 assert row.pr_url == f"https://github.com/{REPO}/pull/7"
448 assert row.pr_number == 7
449 assert row.detail == ("time.time",)
450 
451 
452def test_review_record_ignores_incomplete_reviews():
453 pr_reviews = [
454 _pr_review(1, status="running", findings=0),
455 _pr_review(2, status="completed", findings=1, detail=("time.time",)),
456 ]
457 model = _assemble([], [], [], pr_advisory=pr_reviews)
458 r = _view(model, Loop.DETERMINISM_REVIEW).record
459 assert r.reviewed == 1 # only the completed one
460 assert r.flagged == 1
461 
462 
463def test_a11y_loop_live_when_running():
464 model = _assemble([], [], [], advisory=[_a11y("running")])
465 a11y = _view(model, Loop.A11Y_REVIEW)
466 assert len(a11y.loops) == 1
467 assert a11y.loops[0].repo == REPO
468 assert a11y.loops[0].live is True
469 
470 
471def test_a11y_loop_omitted_when_no_execution():
472 # Like the review loop: a11y is scoped to the Temporal repos, so a repo
473 # with no a11y loop is left out, not shown as a dead one.
474 assert _view(_assemble([], [], []), Loop.A11Y_REVIEW).loops == ()
475 
476 
477def test_a11y_record_counts_findings_clean_and_total():
478 pr_a11y = [
479 _pr_a11y(1, findings=2, detail=("missing-alt", "unlabeled-control")),
480 _pr_a11y(2, findings=0),
481 _pr_a11y(3, findings=1, detail=("missing-alt",)),
482 ]
483 model = _assemble([], [], [], advisory=[_a11y()], pr_advisory=pr_a11y)
484 r = _view(model, Loop.A11Y_REVIEW).record
485 assert (r.reviewed, r.flagged, r.clean, r.findings) == (3, 2, 1, 3)
486 assert r.repos_covered == 1
487 
488 
489def test_a11y_row_attributes_repo_and_pr_url():
490 model = _assemble(
491 [],
492 [],
493 [],
494 pr_advisory=[_pr_a11y(7, findings=1, detail=("missing-alt",))],
495 )
496 row = _view(model, Loop.A11Y_REVIEW).rows[0]
497 assert row.repo == REPO
498 assert row.pr_url == f"https://github.com/{REPO}/pull/7"
499 assert row.pr_number == 7
500 assert row.detail == ("missing-alt",)
501 
502 
503def test_a11y_record_ignores_incomplete_reviews():
504 pr_a11y = [
505 _pr_a11y(1, status="running", findings=0),
506 _pr_a11y(2, status="completed", findings=1, detail=("missing-alt",)),
507 ]
508 model = _assemble([], [], [], pr_advisory=pr_a11y)
509 r = _view(model, Loop.A11Y_REVIEW).record
510 assert r.reviewed == 1 # only the completed one
511 assert r.flagged == 1
512 
513 
514def test_advisory_loops_do_not_cross_attribute():
515 # The two loops share the generic read-model path but are partitioned by
516 # their loop tag: a determinism review never lands on the a11y view, and
517 # each carries only its own findings.
518 model = _assemble(
519 [],
520 [],
521 [],
522 advisory=[_review(), _a11y()],
523 pr_advisory=[
524 _pr_review(1, findings=1, detail=("time.time",)),
525 _pr_a11y(2, findings=1, detail=("missing-alt",)),
526 ],
527 )
528 det = _view(model, Loop.DETERMINISM_REVIEW)
529 a11y = _view(model, Loop.A11Y_REVIEW)
530 assert [r.pr_number for r in det.rows] == [1]
531 assert det.rows[0].detail == ("time.time",)
532 assert [r.pr_number for r in a11y.rows] == [2]
533 assert a11y.rows[0].detail == ("missing-alt",)
⋯ 481 lines hidden (lines 534–1014)
534 
535 
536# ── Earned-autonomy class gates (the shadow gate) ────────────────────────────
537def _allow(**overrides: object) -> AutonomyPolicy:
538 base = {
539 "min_rate": 0.95,
540 "min_decided": 3,
541 "window_days": 90,
542 "allowlisted_repos": frozenset({REPO}),
543 }
544 base.update(overrides)
545 return AutonomyPolicy(**base) # type: ignore[arg-type]
546 
547 
548def _assemble_p(
549 prs: list[GithubPr],
550 bumps: list[BumpExecution],
551 policy: AutonomyPolicy,
552 outcomes: dict[tuple[str, int], str] | None = None,
553 environment: str = "",
554) -> DashboardModel:
555 return read_model.assemble(
556 now=NOW,
557 repos=(REPO,),
558 loops=(Loop.DEPENDENCY_PATCH,),
559 policy=policy,
560 scan_interval_seconds=86_400,
561 advisory_intervals={
562 Loop.DETERMINISM_REVIEW: 300,
563 Loop.A11Y_REVIEW: 300,
564 },
565 github=(tuple(prs), None),
566 temporal=(((), tuple(bumps), (), ()), None),
567 telemetry=_telemetry_off(),
568 outcomes=outcomes,
569 environment=environment,
570 )
571 
572 
573def _held(numbers: tuple[int, ...]) -> dict[tuple[str, int], str]:
574 """Post-merge outcomes marking each PR number held (the defect bearing)."""
575 return {(REPO, n): "held" for n in numbers}
576 
577 
578def _clean_green_merge(
579 number: int, package: str
580) -> tuple[GithubPr, BumpExecution]:
581 """A merged PR plus the Temporal bump giving it a clean+green reading."""
582 opened = datetime(2026, 5, 20, 12, 0, tzinfo=UTC)
583 merged = datetime(2026, 5, 20, 12, 15, tzinfo=UTC)
584 pr = _pr(
585 number, package, "merged", verdict="clean", opened=opened, merged=merged
586 )
587 bump = _bump(
588 f"{package}-1.0.0",
589 "completed",
590 pr_number=number,
591 verdict="clean",
592 ci="passed",
593 )
594 return pr, bump
595 
596 
597def test_class_gate_earned_for_clean_green_history():
598 pairs = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2, 3, 4)]
599 prs = [p for p, _ in pairs]
600 bumps = [b for _, b in pairs]
601 # All four held post-merge -> the defect bearing has evidence and is clean.
602 model = _assemble_p(prs, bumps, _allow(), outcomes=_held((1, 2, 3, 4)))
603 assert len(model.class_gates) == 1
604 g = model.class_gates[0]
605 assert (g.repo, g.loop) == (REPO, "dependency-patch")
606 assert g.decided == 4
607 assert g.merged == 4
608 assert g.merge_rate == 1.0
609 assert g.determined == 4
610 assert g.defects == 0
611 assert g.defect_rate == 0.0
612 assert g.earned is True
613 assert g.blocker is None
614 # 4 merges over a 90d window (~12.86 weeks); all clean+green -> reclaimable.
615 assert g.approvals_per_week > 0
616 assert g.reclaim_per_week == g.approvals_per_week
617 
618 
619def test_class_gate_not_earned_with_a_post_merge_defect():
620 # Perfect rate and enough confirmed, but one merge broke -> the second
621 # bearing fails (zero-tolerance default), so the gate stays shut.
622 pairs = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2, 3, 4)]
623 outcomes = {(REPO, 1): "held", (REPO, 2): "held", (REPO, 3): "held"}
624 outcomes[(REPO, 4)] = "broke"
625 model = _assemble_p(
626 [p for p, _ in pairs], [b for _, b in pairs], _allow(), outcomes
627 )
628 g = model.class_gates[0]
629 assert g.defects == 1
630 assert g.earned is False
631 assert g.blocker is not None and "defect rate" in g.blocker
632 
633 
634def test_class_gate_not_earned_below_min_decided():
635 pairs = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2)]
636 model = _assemble_p(
637 [p for p, _ in pairs], [b for _, b in pairs], _allow(min_decided=3)
638 )
639 g = model.class_gates[0]
640 assert g.decided == 2
641 assert g.earned is False
642 assert g.blocker == "only 2/3 decided recently"
643 
644 
645def test_class_gate_windows_out_old_decisions():
646 # One recent merge, one merged before the 90-day window opened.
647 recent = _pr(
648 1,
649 "fresh",
650 "merged",
651 verdict="clean",
652 opened=datetime(2026, 5, 1, tzinfo=UTC),
653 merged=datetime(2026, 5, 1, 0, 10, tzinfo=UTC),
654 )
655 old = _pr(
656 2,
657 "stale",
658 "merged",
659 verdict="clean",
660 opened=datetime(2026, 1, 1, tzinfo=UTC),
661 merged=datetime(2026, 1, 1, 0, 10, tzinfo=UTC),
662 )
663 model = _assemble_p([recent, old], [], _allow())
664 g = model.class_gates[0]
665 assert g.decided == 1 # the old merge fell out of the window
666 
667 
668def test_class_gate_reclaim_excludes_unverified_merges():
669 # A merged PR with a clean body verdict but NO CI reading is not counted as
670 # reclaimable: without a green oracle we cannot claim it would auto-merge.
671 pr = _pr(
672 1,
673 "lodash",
674 "merged",
675 verdict="clean",
676 opened=datetime(2026, 5, 25, tzinfo=UTC),
677 merged=datetime(2026, 5, 25, 0, 10, tzinfo=UTC),
678 )
679 model = _assemble_p([pr], [], _allow(min_decided=1))
680 g = model.class_gates[0]
681 assert g.merged == 1
682 assert g.reclaim_per_week == 0.0
683 
684 
685def test_class_gate_counts_only_current_environment():
686 # Two merges under the prior env (e4b) and three under the current (26b).
687 # Only the current-env merges count; the prior ones are reset but surfaced.
688 prior = [
689 _pr(
690 n,
691 f"old{n}",
692 "merged",
693 verdict="clean",
694 opened=datetime(2026, 5, 10, tzinfo=UTC),
695 merged=datetime(2026, 5, 10, 0, 5, tzinfo=UTC),
696 env="gemma4-e4b",
697 )
698 for n in (1, 2)
699 ]
700 current = [
701 _pr(
702 n,
703 f"new{n}",
704 "merged",
705 verdict="clean",
706 opened=datetime(2026, 5, 20, tzinfo=UTC),
707 merged=datetime(2026, 5, 20, 0, 5, tzinfo=UTC),
708 env="gemma4-26b",
709 )
710 for n in (3, 4, 5)
711 ]
712 held = {(REPO, n): "held" for n in (3, 4, 5)}
713 model = _assemble_p(
714 prior + current,
715 [],
716 _allow(),
717 outcomes=held,
718 environment="gemma4-26b",
719 )
720 g = model.class_gates[0]
721 assert g.decided == 3 # only the current-environment merges
722 assert g.prior_env_decided == 2 # the e4b record, reset but legible
723 assert g.earned is True # 3 current, all held, 0 defects
724 
725 
726def test_class_gate_resets_when_only_prior_environment_history_exists():
727 # Everything was earned under e4b; the current env is 26b -> reset to zero.
728 prior = [
729 _pr(
730 n,
731 f"old{n}",
732 "merged",
733 verdict="clean",
734 opened=datetime(2026, 5, 10, tzinfo=UTC),
735 merged=datetime(2026, 5, 10, 0, 5, tzinfo=UTC),
736 env="gemma4-e4b",
737 )
738 for n in (1, 2, 3, 4)
739 ]
740 held = {(REPO, n): "held" for n in (1, 2, 3, 4)}
741 model = _assemble_p(
742 prior, [], _allow(), outcomes=held, environment="gemma4-26b"
743 )
744 g = model.class_gates[0]
745 assert g.decided == 0
746 assert g.prior_env_decided == 4
747 assert g.earned is False
748 
749 
750# ── Adversarial canary probes (segregation) ──────────────────────────────────
751def test_canary_rows_excluded_from_bearings_and_tallied_in_probes():
752 # A real merged bump plus two canaries (to 99.99.99): one closed (caught),
753 # one merged (escaped). The canaries must NOT touch the genuine bearings.
754 prs = [
755 _pr(1, "real", "merged", to_version="1.0.1", opened=NOW, merged=NOW),
756 _pr(2, "canary-closed", "closed", to_version="99.99.99", opened=NOW),
757 _pr(
758 3,
759 "canary-merged",
760 "merged",
761 to_version="99.99.99",
762 opened=NOW,
763 merged=NOW,
764 ),
765 ]
766 model = _assemble(prs, [], [])
767 # genuine bearings see only the one real bump
768 assert model.track_record.opened == 1
769 assert model.track_record.merged == 1
770 assert [r.package for r in model.bumps] == ["real"]
771 # the canaries are tallied apart
772 assert model.probes.total == 2
773 assert model.probes.caught == 1 # the closed one
774 assert model.probes.escaped == 1 # the merged one — a guardrail hole
775 assert model.probes.pending == 0
776 
777 
778def test_no_canaries_means_empty_probes():
779 prs = [_pr(1, "real", "merged", to_version="1.0.1", opened=NOW, merged=NOW)]
780 p = _assemble(prs, [], []).probes
781 assert (p.total, p.caught, p.escaped, p.pending) == (0, 0, 0, 0)
782 
783 
784# ── Reliability (post-merge outcome leg) ─────────────────────────────────────
785def _assemble_outcomes(
786 prs: list[GithubPr], outcomes: dict[tuple[str, int], str]
787) -> DashboardModel:
788 return read_model.assemble(
789 now=NOW,
790 repos=(REPO,),
791 scan_interval_seconds=86_400,
792 advisory_intervals={
793 Loop.DETERMINISM_REVIEW: 300,
794 Loop.A11Y_REVIEW: 300,
795 },
796 github=(tuple(prs), None),
797 temporal=(((), (), (), ()), None),
798 telemetry=_telemetry_off(),
799 outcomes=outcomes,
800 reliability_window_days=90,
801 )
802 
803 
804def test_reliability_counts_and_defect_rate():
805 prs = [
806 _pr(1, "a", "merged", opened=NOW, merged=NOW),
807 _pr(2, "b", "merged", opened=NOW, merged=NOW),
808 _pr(3, "c", "merged", opened=NOW, merged=NOW),
809 _pr(4, "d", "merged", opened=NOW, merged=NOW),
810 ]
811 outcomes = {
812 (REPO, 1): "held",
813 (REPO, 2): "held",
814 (REPO, 3): "broke",
815 (REPO, 4): "reverted",
816 }
817 r = _assemble_outcomes(prs, outcomes).reliability
818 assert (r.held, r.broke, r.reverted) == (2, 1, 1)
819 assert r.determined == 4
820 assert r.unverified == 0
821 assert r.defect_rate == 0.5 # (1 broke + 1 reverted) / 4
822 
823 
824def test_reliability_unknown_is_unverified_not_held():
825 prs = [_pr(1, "a", "merged", opened=NOW, merged=NOW)]
826 r = _assemble_outcomes(prs, {(REPO, 1): "unknown"}).reliability
827 assert r.unverified == 1
828 assert r.held == 0
829 assert r.determined == 0
830 assert r.defect_rate is None # nothing determined -> no rate, not 0%
831 
832 
833def test_post_merge_none_when_outcome_absent():
834 # A merged PR with no outcome entry (older than the window) carries no
835 # post_merge tag and is not counted in reliability.
836 prs = [_pr(9, "old", "merged", opened=NOW, merged=NOW)]
837 model = _assemble_outcomes(prs, {})
838 assert model.bumps[0].post_merge is None
839 rel = model.reliability
840 assert (rel.held, rel.broke, rel.reverted, rel.unverified) == (0, 0, 0, 0)
841 
842 
843def test_post_merge_tag_attaches_to_the_right_row():
844 prs = [
845 _pr(1, "a", "merged", opened=NOW, merged=NOW),
846 _pr(2, "b", "merged", opened=NOW, merged=NOW),
847 ]
848 model = _assemble_outcomes(prs, {(REPO, 1): "broke"})
849 by_num = {r.pr_number: r.post_merge for r in model.bumps}
850 assert by_num[1] == "broke"
851 assert by_num[2] is None
852 
853 
854def test_class_gate_reclaim_is_zero_until_earned():
855 # Two clean+green merges, but min_decided=5 -> the class is NOT earned, so
856 # reclaim is zero: an un-earned class's gate would not move, hence reclaims
857 # nothing, even though clean-and-green merges exist.
858 pairs = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2)]
859 model = _assemble_p(
860 [p for p, _ in pairs], [b for _, b in pairs], _allow(min_decided=5)
861 )
862 g = model.class_gates[0]
863 assert g.earned is False
864 assert g.merged == 2
865 assert g.reclaim_per_week == 0.0
866 assert g.approvals_per_week > 0 # the cost is still real
867 
868 
869def test_gate_marks_open_pr_would_auto_merge_on_earned_class():
870 history = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2, 3)]
871 prs = [p for p, _ in history]
872 bumps = [b for _, b in history]
873 # An open, clean PR with a green CI reading on the earned allowlisted class.
874 prs.append(_pr(9, "axios", "open", verdict="clean", opened=NOW))
875 bumps.append(
876 _bump(
877 "axios-1.0.0",
878 "completed",
879 pr_number=9,
880 verdict="clean",
881 ci="passed",
882 )
883 )
884 model = _assemble_p(prs, bumps, _allow(), outcomes=_held((1, 2, 3)))
885 open_row = next(r for r in model.gate if r.pr_number == 9)
886 assert open_row.would_auto_merge is True
887 assert open_row.held_reason is None
888 
889 
890def test_gate_holds_open_pr_with_reason_when_not_allowlisted():
891 history = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2, 3)]
892 prs = [p for p, _ in history]
893 bumps = [b for _, b in history]
894 prs.append(_pr(9, "axios", "open", verdict="clean", opened=NOW))
895 bumps.append(
896 _bump(
897 "axios-1.0.0",
898 "completed",
899 pr_number=9,
900 verdict="clean",
901 ci="passed",
902 )
903 )
904 # Earned history, but the repo is NOT allowlisted -> held on the switch.
905 model = _assemble_p(
906 prs,
907 bumps,
908 _allow(allowlisted_repos=frozenset()),
909 outcomes=_held((1, 2, 3)),
910 )
911 open_row = next(r for r in model.gate if r.pr_number == 9)
912 assert open_row.would_auto_merge is False
913 assert open_row.held_reason == "auto-merge not enabled for this repo"
914 
915 
916def test_gate_holds_open_pr_on_pending_ci():
917 history = [_clean_green_merge(n, f"pkg{n}") for n in (1, 2, 3)]
918 prs = [p for p, _ in history]
919 bumps = [b for _, b in history]
920 # Open, clean, but no CI reading yet -> held on CI even though earned.
921 prs.append(_pr(9, "axios", "open", verdict="clean", opened=NOW))
922 model = _assemble_p(prs, bumps, _allow(), outcomes=_held((1, 2, 3)))
923 open_row = next(r for r in model.gate if r.pr_number == 9)
924 assert open_row.would_auto_merge is False
925 assert open_row.held_reason == "CI pending"
926 
927 
928def test_bump_loops_partition_each_loop_into_its_own_view():
929 # Two loops, one merged PR each: every loop gets its own self-contained view
930 # (its own bumps + track record), and a PR never crosses loops.
931 dep = _pr(1, "left-pad", "merged", verdict="clean", loop="dependency-patch")
932 sec = _pr(2, "left-pad", "merged", verdict="clean", loop="security-patch")
933 model = read_model.assemble(
934 now=NOW,
935 repos=(REPO,),
936 loops=(Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH),
937 scan_interval_seconds=86_400,
938 advisory_intervals={
939 Loop.DETERMINISM_REVIEW: 300,
940 Loop.A11Y_REVIEW: 300,
941 },
942 github=((dep, sec), None),
943 temporal=(((), (), (), ()), None),
944 telemetry=_telemetry_off(),
945 )
946 by_loop = {v.loop: v for v in model.bump_loops}
947 assert set(by_loop) == {"dependency-patch", "security-patch"}
948 assert by_loop["dependency-patch"].title == "Dependency-patch"
949 assert [r.pr_number for r in by_loop["dependency-patch"].bumps] == [1]
950 assert [r.pr_number for r in by_loop["security-patch"].bumps] == [2]
951 # Each per-loop track record counts only its own merge.
952 assert by_loop["dependency-patch"].track_record.merged == 1
953 assert by_loop["security-patch"].track_record.merged == 1
954 # The combined top-level view still sees both.
955 assert model.track_record.merged == 2
956 
957 
958def test_dead_code_loop_renders_removal_shape():
959 # A removal carries no version: its row reads "left-pad -> unused" with no
960 # from-version, and it lands in its own dead-code view.
961 rm = _pr(3, "left-pad", "open", to_version=None, loop="dead-code")
962 model = read_model.assemble(
963 now=NOW,
964 repos=(REPO,),
965 loops=(Loop.DEAD_CODE,),
966 scan_interval_seconds=86_400,
967 advisory_intervals={
968 Loop.DETERMINISM_REVIEW: 300,
969 Loop.A11Y_REVIEW: 300,
970 },
971 github=((rm,), None),
972 temporal=(((), (), (), ()), None),
973 telemetry=_telemetry_off(),
974 )
975 by_loop = {v.loop: v for v in model.bump_loops}
976 assert by_loop["dead-code"].title == "Dead-code"
977 row = by_loop["dead-code"].bumps[0]
978 assert row.package == "left-pad"
979 assert row.to_version == "unused"
980 assert row.from_version is None
981 
982 
983def test_bump_loops_attribute_failures_by_workflow_id():
984 # A segmentless id is dependency-patch; a security-patch-segmented id is
985 # security-patch — failures land in the right loop's view.
986 dep_fail = _bump("left-pad-1.0.0", "failed", reason="boom")
987 sec_fail = BumpExecution(
988 workflow_id="froot-bump-security-patch-mseeks-revisionist-x-1.0.0",
989 status="failed",
990 start=datetime(2026, 6, 2, 19, 45, tzinfo=UTC),
991 close=None,
992 verdict=None,
993 ci=None,
994 pr_number=None,
995 repo=REPO,
996 reason="boom",
997 )
998 model = read_model.assemble(
999 now=NOW,
1000 repos=(REPO,),
1001 loops=(Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH),
1002 scan_interval_seconds=86_400,
1003 advisory_intervals={
1004 Loop.DETERMINISM_REVIEW: 300,
1005 Loop.A11Y_REVIEW: 300,
1006 },
1007 github=((), None),
1008 temporal=(((), (dep_fail, sec_fail), (), ()), None),
1009 telemetry=_telemetry_off(),
1011 by_loop = {v.loop: v for v in model.bump_loops}
1012 assert len(by_loop["dependency-patch"].failures) == 1
1013 assert len(by_loop["security-patch"].failures) == 1
1014 assert "security-patch" in by_loop["security-patch"].failures[0].workflow_id