Overview

The dashboard was an 820px-wide single-column scroll of ~13 table sections, each with a foldable 'why' essay — a word-bomb that buried the thesis. This redesign makes it full-screen and tabbed, and puts the gate first: each loop's hero is the four trust bearings flowing into the earned/hold decision. Two parts: a per-loop data layer, then a rewritten renderer.

Per-loop views — each trust class its own standing

dependency-patch and security-patch are distinct trust classes that never share a record (§3.9), so each earns its own tab. LoopView holds one loop's complete standing — the same shape the combined view has, scoped to one loop.

src/froot/dashboard/model.py · 479 lines
src/froot/dashboard/model.py479 lines · Python
⋯ 391 lines hidden (lines 1–391)
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 ReviewLoop(Frozen):
327 """Liveness of one repo's determinism-review loop (the transitive ring).
328 
329 Attributes:
330 repo: The ``owner/name`` slug this loop reviews.
331 status: The review workflow status (``running`` /
332 ``continued_as_new`` / ``terminated`` / ...), lowercased.
333 live: True when the loop is actively self-scheduling.
334 last_tick: When the current review execution started (≈ the last tick),
335 or ``None`` if no review workflow exists for the repo.
336 """
337 
338 repo: str
339 status: str
340 live: bool
341 last_tick: datetime | None
342 
343 
344class ReviewRow(Frozen):
345 """One per-PR determinism review, from its ``PrReviewWorkflow`` result.
346 
347 Attributes:
348 repo: The ``owner/name`` slug the PR belongs to.
349 pr_number: The reviewed PR number, if known.
350 pr_url: The PR's web URL, if it can be formed.
351 head_sha: The head commit the review ran against.
352 findings: How many transitive hazards the review surfaced.
353 rules: The distinct banned calls flagged (``datetime.datetime.now``…).
354 comment_url: The advisory comment's URL, if one was posted.
355 status: The review workflow status (``completed`` / ``running`` / ...).
356 reviewed_at: When the review closed, or started if still running.
357 """
358 
359 repo: str
360 pr_number: int | None
361 pr_url: str | None
362 head_sha: str | None
363 findings: int
364 rules: tuple[str, ...]
365 comment_url: str | None
366 status: str
367 reviewed_at: datetime | None
368 
369 
370class ReviewRecord(Frozen):
371 """The determinism reviewer's headline, derived from completed reviews.
372 
373 The hazard-resolved rate (was a flagged hazard gone on a later commit?) is
374 a later addition — it needs accumulated cross-commit history, so it is not
375 here yet.
376 
377 Attributes:
378 reviewed: Completed per-PR reviews in the recent window.
379 flagged: Reviews that surfaced at least one hazard.
380 clean: Reviews that surfaced none.
381 hazards: Total hazards surfaced across all reviews.
382 repos_covered: Distinct repos with a live review loop.
383 """
384 
385 reviewed: int
386 flagged: int
387 clean: int
388 hazards: int
389 repos_covered: int
390 
391 
392class LoopView(Frozen):
393 """One bump loop's complete standing — the same treatment for every loop.
394 
395 dependency-patch and security-patch are distinct trust classes (§3.9) that
396 never share a record, so each gets its own self-contained view: its gate and
397 four bearings, its queue, its detail. Built by partitioning the bump rows by
398 loop and running the same aggregates the combined view uses, so a loop's tab
399 is exactly the combined dashboard scoped to that one loop.
400 
401 Attributes:
402 loop: The loop key (``dependency-patch`` / ``security-patch``).
403 title: The human title for the tab.
404 scan_loops: Liveness of this loop's per-repo scan schedules.
405 scan_interval_seconds: This loop's scan cadence (telemetry-in-context).
406 track_record: This loop's reputation headline.
407 class_gates: This loop's per-repo earned-autonomy standing (the gate).
408 verification: This loop's CI-oracle breakdown.
409 reliability: This loop's post-merge outcome leg.
410 probes: This loop's adversarial canary tally.
411 judgment: This loop's model-verdict mix and calibration.
412 gate: This loop's open PRs awaiting a human, freshest last.
413 bumps: This loop's proposed bumps, newest first.
414 failures: This loop's bump loops that did not close.
415 """
416 
417 loop: str
418 title: str
419 scan_loops: tuple[ScanLoop, ...]
420 scan_interval_seconds: int
421 track_record: TrackRecord
422 class_gates: tuple[ClassGate, ...]
423 verification: Verification
424 reliability: Reliability
425 probes: Probes
426 judgment: Judgment
427 gate: tuple[BumpRow, ...]
428 bumps: tuple[BumpRow, ...]
429 failures: tuple[Failure, ...]
430 
431 
⋯ 48 lines hidden (lines 432–479)
432class DashboardModel(Frozen):
433 """The whole 10,000ft view, fully derived and ready to render.
434 
435 Attributes:
436 generated_at: When this view was assembled (UTC).
437 repos_configured: The repos froot is pointed at (``FROOT_REPOS``).
438 scan_interval_seconds: The configured gap between scan ticks.
439 sources: Per-source health for this request.
440 scan_loops: Liveness of each repo's scan schedule.
441 track_record: The reputation headline.
442 class_gates: The earned-autonomy standing per (repo, loop) — the gate.
443 verification: The CI-oracle breakdown.
444 reliability: Did the merges hold post-merge (the outcome leg, coarse).
445 probes: Adversarial canary results, kept apart from the real bearings.
446 judgment: The model-verdict mix and calibration.
447 gate: Open PRs awaiting a human, the freshest last.
448 bumps: Every proposed bump, newest first (the detail behind the stats).
449 failures: Bump loops that did not close.
450 review_interval_seconds: The configured gap between review poll ticks.
451 review_loops: Liveness of each Temporal repo's determinism-review loop.
452 review_record: The determinism reviewer's headline.
453 reviews: Every per-PR determinism review, newest first.
454 telemetry: Trace-derived run telemetry (best-effort).
455 bump_loops: One self-contained view per bump loop — the per-loop tabs.
456 The top-level bump aggregates above are the combined ("all loops")
457 view; these split it per trust class.
458 """
459 
460 generated_at: datetime
461 repos_configured: tuple[str, ...]
462 scan_interval_seconds: int
463 sources: tuple[SourceHealth, ...]
464 scan_loops: tuple[ScanLoop, ...]
465 track_record: TrackRecord
466 class_gates: tuple[ClassGate, ...]
467 verification: Verification
468 reliability: Reliability
469 probes: Probes
470 judgment: Judgment
471 gate: tuple[BumpRow, ...]
472 bumps: tuple[BumpRow, ...]
473 failures: tuple[Failure, ...]
474 review_interval_seconds: int
475 review_loops: tuple[ReviewLoop, ...]
476 review_record: ReviewRecord
477 reviews: tuple[ReviewRow, ...]
478 telemetry: RunTelemetry
479 bump_loops: tuple[LoopView, ...] = ()

The read-model partitions the already-computed bump rows by loop and runs the same aggregates per loop, so a loop's tab is the whole dashboard scoped to one loop. Failures live in Temporal, not the PR rows, so they are attributed by workflow id — the segmentless ids are dependency-patch, the security-patch-segmented ones are security-patch.

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

The gate hero — four bearings into one decision

The hero of every loop tab. The four bearings: approval rate and defect rate come from the record; the adversarial probe is the loop's escaped-canary count; the deep review runs per-PR at the merge, so it shows 'armed' (always-on) rather than a record figure. They flow into the gate node (classes earned) and the outcome — HOLD / EARNED / AUTO-MERGE.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 266 lines hidden (lines 1–266)
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 BumpRow,
25 ClassGate,
26 DashboardModel,
27 LoopView,
28 ReviewRow,
29 RunTelemetry,
30 )
31 
32_CSS = """
33:root{--fg:#13151a;--mut:#6b7280;--faint:#9aa1ac;--line:#e7e9ee;--bg:#fbfbfc;
34--panel:#fff;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da;
35--accentbg:#ddf0ff;--chip:#f2f4f7;--node:#eef6ff}
36@media(prefers-color-scheme:dark){:root{--fg:#e8eaed;--mut:#9aa1ac;
37--faint:#6b7280;--line:#23262d;--bg:#0a0b0d;--panel:#121419;--ok:#3fb950;
38--warn:#d29922;--bad:#f85149;--accent:#58a6ff;--accentbg:#10263f;
39--chip:#1a1d24;--node:#10263f}}
40*{box-sizing:border-box}
41body{margin:0;background:var(--bg);color:var(--fg);
42font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
43-webkit-font-smoothing:antialiased}
44main{padding:0 clamp(16px,3vw,44px) 64px}
45a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
46.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}
47.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
48.bad{color:var(--bad)}
49/* header */
50header{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 20px;
51padding:22px 0 16px;border-bottom:1px solid var(--line)}
52h1{font-size:19px;margin:0;letter-spacing:-.02em;font-weight:700}
53h1 .v{color:var(--faint);font-weight:400;font-size:13px;margin-left:8px}
54.hstatus{display:flex;align-items:center;gap:7px;font-size:13px}
55.hmeta{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px 16px;
56color:var(--mut);font-size:12px}
57.dot{display:inline-block;width:8px;height:8px;border-radius:50%}
58.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
59.dot.bad{background:var(--bad)}.dot.mute{background:var(--faint)}
60/* tabs (CSS-only) */
61.tabin{position:absolute;opacity:0;pointer-events:none}
62nav.tabbar{display:flex;flex-wrap:wrap;gap:4px;margin:14px 0 0;
63border-bottom:1px solid var(--line)}
64nav.tabbar label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
65padding:9px 15px;font-size:13px;font-weight:600;color:var(--mut);
66border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
67nav.tabbar label:hover{color:var(--fg)}
68nav.tabbar label .badge{font-weight:600;font-size:11px;color:var(--faint);
69background:var(--chip);border-radius:9px;padding:1px 7px}
70.panel{display:none;padding:22px 0 0;animation:fade .15s ease}
71@keyframes fade{from{opacity:.4}to{opacity:1}}
72/* gate hero */
73.hero{display:grid;grid-template-columns:minmax(320px,1.1fr) minmax(280px,1fr);
74gap:18px 26px;align-items:start;margin:0 0 6px}
75@media(max-width:760px){.hero{grid-template-columns:1fr}}
76.heroh{font-size:11px;text-transform:uppercase;letter-spacing:.09em;
77color:var(--mut);font-weight:700;margin:0 0 12px}
78.gateflow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
79.bearings{display:flex;flex-direction:column;gap:6px;min-width:150px}
80.bearing{display:flex;align-items:center;justify-content:space-between;gap:10px;
81background:var(--chip);border-radius:7px;padding:6px 10px;font-size:12px}
82.bearing .bl{color:var(--mut)}
83.bearing .bv{font-weight:600;font-variant-numeric:tabular-nums}
84.bearing.armed .bv{color:var(--faint);font-weight:500}
85.flowarrow{color:var(--faint);font-size:18px;line-height:1}
86.gatenode{flex:0 0 auto;text-align:center;border:2px solid var(--accent);
87background:var(--node);border-radius:12px;padding:12px 16px;min-width:96px}
88.gatenode .gl{font-size:10px;text-transform:uppercase;letter-spacing:.08em;
89color:var(--accent);font-weight:700}
90.gatenode .gv{font-size:22px;font-weight:700;line-height:1.15;
91font-variant-numeric:tabular-nums}
92.gatenode .gs{font-size:11px;color:var(--mut)}
93.outcome{flex:0 0 auto;border-radius:10px;padding:11px 15px;text-align:center;
94border:1px solid var(--line)}
95.outcome .ot{font-size:15px;font-weight:700;letter-spacing:.01em}
96.outcome .os{font-size:11px;color:var(--mut);margin-top:1px}
97.outcome.act{background:color-mix(in srgb,var(--ok) 12%,transparent);
98border-color:color-mix(in srgb,var(--ok) 40%,transparent)}
99.outcome.act .ot{color:var(--ok)}
100.outcome.hold .ot{color:var(--mut)}
101.caption{color:var(--mut);font-size:12px;margin:8px 0 0;line-height:1.45}
102/* class table inside the hero */
103.classes{width:100%;border-collapse:collapse;font-size:12.5px}
104.classes th,.classes td{text-align:left;padding:6px 10px 6px 0;
105border-bottom:1px solid var(--line);vertical-align:baseline}
106.classes th{color:var(--mut);font-weight:600;font-size:10.5px;
107text-transform:uppercase;letter-spacing:.05em}
108.classes td.r{font-variant-numeric:tabular-nums}
109.pill{display:inline-block;font-size:11px;font-weight:600;border-radius:20px;
110padding:1px 9px}
111.pill.ok{color:var(--ok);
112background:color-mix(in srgb,var(--ok) 14%,transparent)}
113.pill.hold{color:var(--mut);background:var(--chip)}
114/* metric cards */
115.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
116gap:10px;margin:22px 0 0}
117.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;
118padding:13px 15px}
119.card .n{font-size:24px;font-weight:700;line-height:1.05;
120font-variant-numeric:tabular-nums}
121.card .l{color:var(--mut);font-size:11.5px;margin-top:3px}
122.card .x{color:var(--faint);font-size:11px;margin-top:5px}
123/* section + detail */
124.sec{margin:26px 0 0}
125.sech{font-size:11px;text-transform:uppercase;letter-spacing:.08em;
126color:var(--mut);font-weight:700;margin:0 0 10px;display:flex;
127align-items:baseline;gap:10px}
128.sech .n{color:var(--faint);font-weight:600;letter-spacing:0}
129details.fold{margin:18px 0 0;border:1px solid var(--line);border-radius:10px;
130background:var(--panel)}
131details.fold>summary{cursor:pointer;list-style:none;padding:11px 15px;
132font-size:12px;font-weight:600;color:var(--fg);display:flex;
133align-items:center;gap:9px}
134details.fold>summary::-webkit-details-marker{display:none}
135details.fold>summary::before{content:"\\25B8";color:var(--faint);font-size:10px}
136details.fold[open]>summary::before{content:"\\25BE"}
137details.fold>summary .c{color:var(--faint);font-weight:600}
138details.fold .body{padding:0 15px 14px}
139table.data{width:100%;border-collapse:collapse;font-size:12.5px}
140table.data th,table.data td{text-align:left;padding:6px 12px 6px 0;
141border-bottom:1px solid var(--line);vertical-align:top}
142table.data th{color:var(--mut);font-weight:600;font-size:10.5px;
143text-transform:uppercase;letter-spacing:.04em}
144table.data td.r{font-variant-numeric:tabular-nums;white-space:nowrap}
145.empty{color:var(--mut);font-size:13px;padding:14px 0;border:1px dashed
146var(--line);border-radius:9px;text-align:center;background:var(--panel)}
147.tags{display:flex;flex-wrap:wrap;gap:6px}
148.t{font-size:11px;border-radius:6px;padding:1px 7px;background:var(--chip);
149color:var(--mut)}
150.t.ok{color:var(--ok)}.t.warn{color:var(--warn)}.t.bad{color:var(--bad)}
151.cad{color:var(--mut);font-size:12px;margin:14px 0 0}
152.cad b{color:var(--fg);font-weight:600}
153footer{margin:40px 0 0;padding-top:16px;border-top:1px solid var(--line);
154color:var(--mut);font-size:12px;line-height:1.65}
155footer b{color:var(--fg);font-weight:600}
156"""
157 
158_CI_CLASS = {
159 "passed": "ok",
160 "failed": "bad",
161 "absent": "mut",
162 "timed_out": "warn",
164_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
165_POST_MERGE_CLASS = {
166 "held": "ok",
167 "broke": "bad",
168 "reverted": "bad",
169 "unknown": "mut",
171_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
172_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
173 
174 
175# ── small formatters ─────────────────────────────────────────────────────────
176def _aware(when: datetime) -> datetime:
177 return when if when.tzinfo else when.replace(tzinfo=UTC)
178 
179 
180def _ago(when: datetime | None, now: datetime) -> str:
181 if when is None:
182 return ""
183 secs = (now - _aware(when)).total_seconds()
184 if secs < 90:
185 return "just now"
186 mins = secs / 60
187 if mins < 90:
188 return f"{round(mins)}m ago"
189 hours = mins / 60
190 if hours < 36:
191 return f"{round(hours)}h ago"
192 return f"{round(hours / 24)}d ago"
193 
194 
195def _until(when: datetime | None, now: datetime) -> str:
196 if when is None:
197 return ""
198 secs = (_aware(when) - now).total_seconds()
199 if secs <= 0:
200 return "due"
201 mins = secs / 60
202 if mins < 90:
203 return f"~{round(mins)}m"
204 hours = mins / 60
205 if hours < 36:
206 return f"~{round(hours)}h"
207 return f"~{round(hours / 24)}d"
208 
209 
210def _pct(rate: float | None) -> str:
211 return "" if rate is None else f"{rate * 100:.0f}%"
212 
213 
214def _dot(kind: str) -> str:
215 return f'<span class="dot {kind}"></span>'
216 
217 
218def _tag(value: str | None, classes: dict[str, str]) -> str:
219 if value is None:
220 return '<span class="t">—</span>'
221 cls = classes.get(value, "")
222 return f'<span class="t {cls}">{escape(value)}</span>'
223 
224 
225def _pr_link(row: BumpRow) -> str:
226 if row.pr_url is None or row.pr_number is None:
227 return '<span class="mut">—</span>'
228 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
229 
230 
231def _short_id(workflow_id: str) -> str:
232 return escape(workflow_id.removeprefix("froot-bump-"))
233 
234 
235# ── header ───────────────────────────────────────────────────────────────────
236def _alive(model: DashboardModel) -> tuple[str, str]:
237 """Global liveness dot + label across every loop."""
238 live = sum(1 for x in model.scan_loops if x.live) + sum(
239 1 for x in model.review_loops if x.live
240 )
241 total = len(model.scan_loops) + len(model.review_loops)
242 if total == 0:
243 return "mute", "no loops configured"
244 kind = "ok" if live == total else ("warn" if live else "bad")
245 return kind, f"{live}/{total} loops live"
246 
247 
248def _header(model: DashboardModel) -> str:
249 kind, label = _alive(model)
250 sources = " ".join(
251 f"{_dot('ok' if s.ok else 'bad')}{escape(s.name)}"
252 for s in model.sources
253 )
254 return (
255 "<header>"
256 '<h1>froot<span class="v mono">gemma4:12b</span></h1>'
257 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
258 f'<span class="hmeta"><span>{sources}</span>'
259 f"<span>{len(model.repos_configured)} repos</span>"
260 f"<span>built {_ago(model.generated_at, model.generated_at)}"
261 " · reload to recompute</span></span>"
262 "</header>"
263 )
264 
265 
266# ── the gate hero (per loop) ─────────────────────────────────────────────────
267def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
268 cls = "bearing armed" if armed else "bearing"
269 return (
270 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
271 f'<span class="bv {kind}">{escape(value)}</span></div>'
272 )
273 
274 
275def _gate_hero(view: LoopView) -> str:
276 t, rel, pr = view.track_record, view.reliability, view.probes
277 earned = sum(1 for g in view.class_gates if g.earned)
278 total = len(view.class_gates)
279 acting = any(r.would_auto_merge for r in view.gate)
280 # The four bearings: rate + defect come from the record; the adversarial
281 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
282 # the merge, so it is shown armed (always-on), not a record figure.
283 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
284 defect_ok = "ok" if not rel.defect_rate else "bad"
285 probe_ok = "ok" if pr.escaped == 0 else "bad"
286 bearings = "".join(
287 (
288 _bearing(
289 "approval rate",
290 _pct(t.merge_rate),
291 rate_ok if t.merge_rate is not None else "mut",
292 ),
293 _bearing(
294 "defect rate",
295 _pct(rel.defect_rate),
296 defect_ok if rel.defect_rate is not None else "mut",
297 ),
298 _bearing(
299 "probe",
300 f"{pr.escaped} escaped" if pr.total else "none",
301 probe_ok if pr.total else "mut",
302 ),
303 _bearing("deep review", "armed", "mut", armed=True),
304 )
305 )
306 if total == 0:
307 node = (
308 '<div class="gatenode"><div class="gl">gate</div>'
309 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
310 )
311 else:
312 node = (
313 f'<div class="gatenode"><div class="gl">earned</div>'
314 f'<div class="gv">{earned}/{total}</div>'
315 '<div class="gs">classes</div></div>'
316 )
317 if acting:
318 out = (
319 '<div class="outcome act"><div class="ot">AUTO-MERGE</div>'
320 '<div class="os">a class is acting now</div></div>'
321 )
322 elif earned:
323 out = (
324 '<div class="outcome act"><div class="ot">EARNED</div>'
325 '<div class="os">acts where allowlisted</div></div>'
326 )
327 else:
328 out = (
329 '<div class="outcome hold"><div class="ot">HOLD</div>'
330 '<div class="os">building the record</div></div>'
331 )
332 flow = (
333 '<div class="gateflow">'
334 f'<div class="bearings">{bearings}</div>'
335 '<div class="flowarrow">&rarr;</div>'
336 f"{node}"
337 '<div class="flowarrow">&rarr;</div>'
338 f"{out}</div>"
339 )
340 caption = (
341 '<p class="caption">A class earns the gate by triangulation: a high '
342 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
343 "evidence. Two further legs guard the live merge — an adversarial "
344 "<b>probe</b> and an independent <b>deep review</b> at merge. "
345 "Auto-merge is allowlist-gated (off by default).</p>"
346 )
347 return (
348 '<div><div class="heroh">Earned autonomy &middot; the gate</div>'
349 f"{flow}{caption}</div>"
350 )
351 
352 
⋯ 411 lines hidden (lines 353–763)
353def _class_table(view: LoopView) -> str:
354 if not view.class_gates:
355 return (
356 '<div><div class="heroh">Classes</div>'
357 '<div class="empty">No classes yet &mdash; a (repo, loop) '
358 "earns the gate from its own track record.</div></div>"
359 )
360 rows = "".join(_class_row(g) for g in view.class_gates)
361 return (
362 '<div><div class="heroh">Per-class standing</div>'
363 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
364 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
365 f"<tbody>{rows}</tbody></table></div>"
366 )
367 
368 
369def _class_row(g: ClassGate) -> str:
370 if g.earned:
371 gate = '<span class="pill ok">earned</span>'
372 else:
373 gate = (
374 f'<span class="pill hold">hold</span> '
375 f'<span class="mut">{escape(g.blocker or "")}</span>'
376 )
377 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
378 budget = (
379 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
380 if g.approvals_per_week
381 else ""
382 )
383 return (
384 f'<tr><td class="mono">{escape(g.repo)}</td>'
385 f'<td class="r">{_pct(g.merge_rate)}</td>'
386 f'<td class="r">{escape(defect)}</td>'
387 f"<td>{gate}</td>"
388 f'<td class="r mut">{escape(budget)}</td></tr>'
389 )
390 
391 
392# ── metric cards (per loop) ──────────────────────────────────────────────────
393def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
394 x = f'<div class="x">{extra}</div>' if extra else ""
395 return (
396 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
397 f'<div class="l">{escape(label)}</div>{x}</div>'
398 )
399 
400 
401def _loop_cards(view: LoopView) -> str:
402 t, v, rel, j, pr = (
403 view.track_record,
404 view.verification,
405 view.reliability,
406 view.judgment,
407 view.probes,
408 )
409 defects = rel.broke + rel.reverted
410 cards = [
411 _card(
412 t.opened,
413 "proposed",
414 f"{t.merged} merged · {t.closed_unmerged} closed",
415 ),
416 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
417 _card(
418 t.open_now,
419 "awaiting you",
420 "open, needs a human",
421 "warn" if t.open_now else "",
422 ),
423 _card(
424 _pct(rel.defect_rate) if rel.determined else "",
425 "defect rate",
426 f"{rel.held} held · {defects} broke",
427 "bad" if defects else "",
428 ),
429 _card(
430 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
431 "CI passed",
432 "the oracle",
433 ),
434 _card(
435 pr.escaped if pr.total else "0",
436 "probes escaped",
437 f"{pr.caught} caught" if pr.total else "no probes yet",
438 "bad" if pr.escaped else "",
439 ),
440 _card(
441 j.clean,
442 "clean verdicts",
443 f"{j.clean_but_failed} mis-judged"
444 if j.clean_but_failed
445 else "judge calibrated",
446 ),
447 _card(rel.held, "merges held", "post-merge, stayed green"),
448 ]
449 return f'<div class="cards">{"".join(cards)}</div>'
450 
451 
452# ── detail tables ────────────────────────────────────────────────────────────
453def _cadence(view: LoopView, now: datetime) -> str:
454 live = sum(1 for s in view.scan_loops if s.live)
455 total = len(view.scan_loops)
456 nxt = ""
457 last = max(
458 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
459 default=None,
460 )
461 if last is not None:
462 due = last + timedelta(seconds=view.scan_interval_seconds)
463 nxt = f" &middot; next {escape(_until(due, now))}"
464 every = round(view.scan_interval_seconds / 3600, 1)
465 return (
466 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
467 f"&middot; every <b>{every}h</b>{nxt}</p>"
468 )
469 
470 
471def _bumps_fold(view: LoopView) -> str:
472 if not view.bumps:
473 return ""
474 rows = "".join(
475 "<tr>"
476 f'<td class="mono">{escape(r.package)}</td>'
477 f'<td class="mono mut">{escape(r.to_version)}</td>'
478 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
479 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
480 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
481 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
482 f"<td>{_pr_link(r)}</td>"
483 "</tr>"
484 for r in view.bumps
485 )
486 return (
487 '<details class="fold"><summary>Bumps '
488 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
489 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
490 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
491 "<th>pr</th></tr></thead>"
492 f"<tbody>{rows}</tbody></table></div></details>"
493 )
494 
495 
496def _failures_fold(view: LoopView) -> str:
497 if not view.failures:
498 return ""
499 rows = "".join(
500 "<tr>"
501 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
502 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
503 f'<td class="mut">{escape(f.reason or "")}</td>'
504 "</tr>"
505 for f in view.failures
506 )
507 return (
508 '<details class="fold"><summary class="bad">Failures '
509 f'<span class="c">{len(view.failures)}</span></summary>'
510 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
511 "<th>kind</th><th>reason</th></tr></thead>"
512 f"<tbody>{rows}</tbody></table></div></details>"
513 )
514 
515 
516# ── panels ───────────────────────────────────────────────────────────────────
517def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
518 queue = _queue_sec(view)
519 return (
520 f'<section class="panel" id="{pid}">'
521 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
522 f"{_loop_cards(view)}"
523 f"{queue}"
524 f"{_cadence(view, now)}"
525 f"{_bumps_fold(view)}{_failures_fold(view)}"
526 "</section>"
527 )
528 
529 
530def _queue_sec(view: LoopView) -> str:
531 if not view.gate:
532 return (
533 '<div class="sec"><div class="sech">Approval queue '
534 '<span class="n">empty</span></div>'
535 '<div class="empty">Nothing awaiting you.</div></div>'
536 )
537 rows = "".join(
538 "<tr>"
539 f'<td class="mono">{escape(r.package)}</td>'
540 f'<td class="mono mut">{escape(r.to_version)}</td>'
541 f"<td>{_queue_badge(r)}</td>"
542 f"<td>{_pr_link(r)}</td>"
543 "</tr>"
544 for r in view.gate
545 )
546 return (
547 '<div class="sec"><div class="sech">Approval queue '
548 f'<span class="n">{len(view.gate)} yours</span></div>'
549 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
550 "<th>gate</th><th>pr</th></tr></thead>"
551 f"<tbody>{rows}</tbody></table></div>"
552 )
553 
554 
555def _queue_badge(row: BumpRow) -> str:
556 if row.would_auto_merge:
557 return '<span class="pill ok">would auto-merge</span>'
558 reason = row.held_reason or "held"
559 return f'<span class="mut">held &middot; {escape(reason)}</span>'
560 
561 
562def _review_panel(model: DashboardModel, pid: str) -> str:
563 r = model.review_record
564 live = sum(1 for x in model.review_loops if x.live)
565 haz = "bad" if r.hazards else ""
566 cards = (
567 '<div class="cards">'
568 f"{_card(r.repos_covered, 'repos covered')}"
569 f"{_card(f'{live}/{len(model.review_loops)}', 'loops live')}"
570 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
571 f"{_card(r.hazards, 'hazards', 'transitive', haz)}"
572 "</div>"
573 )
574 body = (
575 '<div class="empty">No PRs reviewed yet.</div>'
576 if not model.reviews
577 else _reviews_table(model.reviews)
578 )
579 note = (
580 '<p class="caption">The transitive ring — advisory. It '
581 "re-derives each open PR's reachable determinism hazards and "
582 "comments; it never blocks a merge.</p>"
583 )
584 every = round(model.review_interval_seconds / 60, 1)
585 cad = (
586 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
587 f"every <b>{every}m</b></p>"
588 )
589 return (
590 f'<section class="panel" id="{pid}">'
591 '<div class="heroh">Determinism review &middot; the transitive ring'
592 f"</div>{note}{cards}{cad}"
593 f'<div class="sec"><div class="sech">Reviews '
594 f'<span class="n">{len(model.reviews)}</span></div>{body}</div>'
595 "</section>"
596 )
597 
598 
599def _reviews_table(reviews: tuple[ReviewRow, ...]) -> str:
600 rows = "".join(
601 "<tr>"
602 f'<td class="mono">{escape(row.repo)}</td>'
603 f"<td>{_review_pr_link(row)}</td>"
604 f"<td>{_findings_cell(row)}</td>"
605 f'<td class="mono mut">{escape(", ".join(row.rules) or "")}</td>'
606 "</tr>"
607 for row in reviews
608 )
609 return (
610 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
611 "<th>findings</th><th>rules</th></tr></thead>"
612 f"<tbody>{rows}</tbody></table>"
613 )
614 
615 
616def _review_pr_link(row: ReviewRow) -> str:
617 if row.pr_url is None or row.pr_number is None:
618 return '<span class="mut">—</span>'
619 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
620 
621 
622def _findings_cell(row: ReviewRow) -> str:
623 if row.findings == 0:
624 return '<span class="ok">clean</span>'
625 label = "hazard" if row.findings == 1 else "hazards"
626 link = ""
627 if row.comment_url:
628 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
629 return f'<span class="bad">{row.findings} {label}</span>{link}'
630 
631 
632def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
633 tel: RunTelemetry = model.telemetry
634 if not tel.available:
635 body = (
636 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
637 "froot traces in the window.</div>"
638 )
639 else:
640 rows = "".join(
641 "<tr>"
642 f'<td class="mono">{escape(a.name)}</td>'
643 f'<td class="r">{a.count}</td>'
644 f'<td class="r">{a.avg_ms:.0f} ms</td>'
645 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
646 "</tr>"
647 for a in tel.activities
648 )
649 last = f"last {_ago(tel.last_activity, now)}"
650 err = "bad" if tel.error_spans else ""
651 head = (
652 '<div class="cards">'
653 f"{_card(tel.total_spans, 'spans', last)}"
654 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
655 f"{_card(f'{tel.window_days}d', 'window')}</div>"
656 )
657 table = (
658 '<div class="sec"><div class="sech">Activity latency</div>'
659 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
660 "<th>avg</th><th>max</th></tr></thead>"
661 f"<tbody>{rows}</tbody></table></div>"
662 )
663 body = head + table
664 note = (
665 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
666 "(trace-derived, best-effort) — latency per activity across "
667 "every loop.</p>"
668 )
669 return (
670 f'<section class="panel" id="{pid}">'
671 '<div class="heroh">Run telemetry &middot; ClickHouse</div>'
672 f"{note}{body}</section>"
673 )
674 
675 
676# ── tabs + page ──────────────────────────────────────────────────────────────
677def _loop_badge(view: LoopView) -> str:
678 earned = sum(1 for g in view.class_gates if g.earned)
679 if view.class_gates and earned:
680 return f"{earned}/{len(view.class_gates)} earned"
681 if view.track_record.open_now:
682 return f"{view.track_record.open_now} open"
683 return str(view.track_record.opened)
684 
685 
686def page(model: DashboardModel) -> str:
687 """Render the whole dashboard as one self-contained HTML document."""
688 now = model.generated_at
689 # (tab-id, panel-id, label, badge, panel-html)
690 tabs: list[tuple[str, str, str, str, str]] = []
691 for i, view in enumerate(model.bump_loops):
692 pid, tid = f"panel-{i}", f"tab-{i}"
693 tabs.append(
694 (
695 tid,
696 pid,
697 view.title,
698 _loop_badge(view),
699 _loop_panel(view, pid, now),
700 )
701 )
702 tabs.append(
703 (
704 "tab-det",
705 "panel-det",
706 "Determinism review",
707 str(model.review_record.reviewed),
708 _review_panel(model, "panel-det"),
709 )
710 )
711 tabs.append(
712 (
713 "tab-tel",
714 "panel-tel",
715 "Telemetry",
716 "live" if model.telemetry.available else "off",
717 _telemetry_panel(model, "panel-tel", now),
718 )
719 )
720 
721 inputs, labels, panels, rules = [], [], [], []
722 for idx, (tid, pid, label, badge, panel) in enumerate(tabs):
723 checked = " checked" if idx == 0 else ""
724 inputs.append(
725 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
726 )
727 labels.append(
728 f'<label for="{tid}">{escape(label)}'
729 f'<span class="badge">{escape(badge)}</span></label>'
730 )
731 panels.append(panel)
732 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
733 rules.append(
734 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
735 "{color:var(--fg);border-bottom-color:var(--accent)}"
736 )
737 tabcss = "".join(rules)
738 return (
739 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
740 '<meta name="viewport" content="width=device-width,initial-scale=1">'
741 "<title>froot &middot; read-model</title>"
742 f"<style>{_CSS}{tabcss}</style></head><body>"
743 + "".join(inputs)
744 + "<main>"
745 + _header(model)
746 + f'<nav class="tabbar">{"".join(labels)}</nav>'
747 + "".join(panels)
748 + _footer()
749 + "</main></body></html>"
750 )
751 
752 
753def _footer() -> str:
754 return (
755 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
756 "auto-merges only on an allowlisted repo where a class has earned its "
757 "gate (the allowlist is empty by default, so commit authority is none "
758 "until a steward opts in). Trust is earned, narrow to one loop on one "
759 "repo, conditional on its environment "
760 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
761 "Everything here is derived per request from GitHub + Temporal + "
762 "ClickHouse; froot keeps no database.</footer>"
763 )

The per-class table beside it is the authoritative detail: each repo's rate, defect rate, earned/blocker, and the steward budget. The hero bearings are the loop-level indicators; this table is the truth per class.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 352 lines hidden (lines 1–352)
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 BumpRow,
25 ClassGate,
26 DashboardModel,
27 LoopView,
28 ReviewRow,
29 RunTelemetry,
30 )
31 
32_CSS = """
33:root{--fg:#13151a;--mut:#6b7280;--faint:#9aa1ac;--line:#e7e9ee;--bg:#fbfbfc;
34--panel:#fff;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da;
35--accentbg:#ddf0ff;--chip:#f2f4f7;--node:#eef6ff}
36@media(prefers-color-scheme:dark){:root{--fg:#e8eaed;--mut:#9aa1ac;
37--faint:#6b7280;--line:#23262d;--bg:#0a0b0d;--panel:#121419;--ok:#3fb950;
38--warn:#d29922;--bad:#f85149;--accent:#58a6ff;--accentbg:#10263f;
39--chip:#1a1d24;--node:#10263f}}
40*{box-sizing:border-box}
41body{margin:0;background:var(--bg);color:var(--fg);
42font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
43-webkit-font-smoothing:antialiased}
44main{padding:0 clamp(16px,3vw,44px) 64px}
45a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
46.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}
47.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
48.bad{color:var(--bad)}
49/* header */
50header{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 20px;
51padding:22px 0 16px;border-bottom:1px solid var(--line)}
52h1{font-size:19px;margin:0;letter-spacing:-.02em;font-weight:700}
53h1 .v{color:var(--faint);font-weight:400;font-size:13px;margin-left:8px}
54.hstatus{display:flex;align-items:center;gap:7px;font-size:13px}
55.hmeta{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px 16px;
56color:var(--mut);font-size:12px}
57.dot{display:inline-block;width:8px;height:8px;border-radius:50%}
58.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
59.dot.bad{background:var(--bad)}.dot.mute{background:var(--faint)}
60/* tabs (CSS-only) */
61.tabin{position:absolute;opacity:0;pointer-events:none}
62nav.tabbar{display:flex;flex-wrap:wrap;gap:4px;margin:14px 0 0;
63border-bottom:1px solid var(--line)}
64nav.tabbar label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
65padding:9px 15px;font-size:13px;font-weight:600;color:var(--mut);
66border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
67nav.tabbar label:hover{color:var(--fg)}
68nav.tabbar label .badge{font-weight:600;font-size:11px;color:var(--faint);
69background:var(--chip);border-radius:9px;padding:1px 7px}
70.panel{display:none;padding:22px 0 0;animation:fade .15s ease}
71@keyframes fade{from{opacity:.4}to{opacity:1}}
72/* gate hero */
73.hero{display:grid;grid-template-columns:minmax(320px,1.1fr) minmax(280px,1fr);
74gap:18px 26px;align-items:start;margin:0 0 6px}
75@media(max-width:760px){.hero{grid-template-columns:1fr}}
76.heroh{font-size:11px;text-transform:uppercase;letter-spacing:.09em;
77color:var(--mut);font-weight:700;margin:0 0 12px}
78.gateflow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
79.bearings{display:flex;flex-direction:column;gap:6px;min-width:150px}
80.bearing{display:flex;align-items:center;justify-content:space-between;gap:10px;
81background:var(--chip);border-radius:7px;padding:6px 10px;font-size:12px}
82.bearing .bl{color:var(--mut)}
83.bearing .bv{font-weight:600;font-variant-numeric:tabular-nums}
84.bearing.armed .bv{color:var(--faint);font-weight:500}
85.flowarrow{color:var(--faint);font-size:18px;line-height:1}
86.gatenode{flex:0 0 auto;text-align:center;border:2px solid var(--accent);
87background:var(--node);border-radius:12px;padding:12px 16px;min-width:96px}
88.gatenode .gl{font-size:10px;text-transform:uppercase;letter-spacing:.08em;
89color:var(--accent);font-weight:700}
90.gatenode .gv{font-size:22px;font-weight:700;line-height:1.15;
91font-variant-numeric:tabular-nums}
92.gatenode .gs{font-size:11px;color:var(--mut)}
93.outcome{flex:0 0 auto;border-radius:10px;padding:11px 15px;text-align:center;
94border:1px solid var(--line)}
95.outcome .ot{font-size:15px;font-weight:700;letter-spacing:.01em}
96.outcome .os{font-size:11px;color:var(--mut);margin-top:1px}
97.outcome.act{background:color-mix(in srgb,var(--ok) 12%,transparent);
98border-color:color-mix(in srgb,var(--ok) 40%,transparent)}
99.outcome.act .ot{color:var(--ok)}
100.outcome.hold .ot{color:var(--mut)}
101.caption{color:var(--mut);font-size:12px;margin:8px 0 0;line-height:1.45}
102/* class table inside the hero */
103.classes{width:100%;border-collapse:collapse;font-size:12.5px}
104.classes th,.classes td{text-align:left;padding:6px 10px 6px 0;
105border-bottom:1px solid var(--line);vertical-align:baseline}
106.classes th{color:var(--mut);font-weight:600;font-size:10.5px;
107text-transform:uppercase;letter-spacing:.05em}
108.classes td.r{font-variant-numeric:tabular-nums}
109.pill{display:inline-block;font-size:11px;font-weight:600;border-radius:20px;
110padding:1px 9px}
111.pill.ok{color:var(--ok);
112background:color-mix(in srgb,var(--ok) 14%,transparent)}
113.pill.hold{color:var(--mut);background:var(--chip)}
114/* metric cards */
115.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
116gap:10px;margin:22px 0 0}
117.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;
118padding:13px 15px}
119.card .n{font-size:24px;font-weight:700;line-height:1.05;
120font-variant-numeric:tabular-nums}
121.card .l{color:var(--mut);font-size:11.5px;margin-top:3px}
122.card .x{color:var(--faint);font-size:11px;margin-top:5px}
123/* section + detail */
124.sec{margin:26px 0 0}
125.sech{font-size:11px;text-transform:uppercase;letter-spacing:.08em;
126color:var(--mut);font-weight:700;margin:0 0 10px;display:flex;
127align-items:baseline;gap:10px}
128.sech .n{color:var(--faint);font-weight:600;letter-spacing:0}
129details.fold{margin:18px 0 0;border:1px solid var(--line);border-radius:10px;
130background:var(--panel)}
131details.fold>summary{cursor:pointer;list-style:none;padding:11px 15px;
132font-size:12px;font-weight:600;color:var(--fg);display:flex;
133align-items:center;gap:9px}
134details.fold>summary::-webkit-details-marker{display:none}
135details.fold>summary::before{content:"\\25B8";color:var(--faint);font-size:10px}
136details.fold[open]>summary::before{content:"\\25BE"}
137details.fold>summary .c{color:var(--faint);font-weight:600}
138details.fold .body{padding:0 15px 14px}
139table.data{width:100%;border-collapse:collapse;font-size:12.5px}
140table.data th,table.data td{text-align:left;padding:6px 12px 6px 0;
141border-bottom:1px solid var(--line);vertical-align:top}
142table.data th{color:var(--mut);font-weight:600;font-size:10.5px;
143text-transform:uppercase;letter-spacing:.04em}
144table.data td.r{font-variant-numeric:tabular-nums;white-space:nowrap}
145.empty{color:var(--mut);font-size:13px;padding:14px 0;border:1px dashed
146var(--line);border-radius:9px;text-align:center;background:var(--panel)}
147.tags{display:flex;flex-wrap:wrap;gap:6px}
148.t{font-size:11px;border-radius:6px;padding:1px 7px;background:var(--chip);
149color:var(--mut)}
150.t.ok{color:var(--ok)}.t.warn{color:var(--warn)}.t.bad{color:var(--bad)}
151.cad{color:var(--mut);font-size:12px;margin:14px 0 0}
152.cad b{color:var(--fg);font-weight:600}
153footer{margin:40px 0 0;padding-top:16px;border-top:1px solid var(--line);
154color:var(--mut);font-size:12px;line-height:1.65}
155footer b{color:var(--fg);font-weight:600}
156"""
157 
158_CI_CLASS = {
159 "passed": "ok",
160 "failed": "bad",
161 "absent": "mut",
162 "timed_out": "warn",
164_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
165_POST_MERGE_CLASS = {
166 "held": "ok",
167 "broke": "bad",
168 "reverted": "bad",
169 "unknown": "mut",
171_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
172_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
173 
174 
175# ── small formatters ─────────────────────────────────────────────────────────
176def _aware(when: datetime) -> datetime:
177 return when if when.tzinfo else when.replace(tzinfo=UTC)
178 
179 
180def _ago(when: datetime | None, now: datetime) -> str:
181 if when is None:
182 return ""
183 secs = (now - _aware(when)).total_seconds()
184 if secs < 90:
185 return "just now"
186 mins = secs / 60
187 if mins < 90:
188 return f"{round(mins)}m ago"
189 hours = mins / 60
190 if hours < 36:
191 return f"{round(hours)}h ago"
192 return f"{round(hours / 24)}d ago"
193 
194 
195def _until(when: datetime | None, now: datetime) -> str:
196 if when is None:
197 return ""
198 secs = (_aware(when) - now).total_seconds()
199 if secs <= 0:
200 return "due"
201 mins = secs / 60
202 if mins < 90:
203 return f"~{round(mins)}m"
204 hours = mins / 60
205 if hours < 36:
206 return f"~{round(hours)}h"
207 return f"~{round(hours / 24)}d"
208 
209 
210def _pct(rate: float | None) -> str:
211 return "" if rate is None else f"{rate * 100:.0f}%"
212 
213 
214def _dot(kind: str) -> str:
215 return f'<span class="dot {kind}"></span>'
216 
217 
218def _tag(value: str | None, classes: dict[str, str]) -> str:
219 if value is None:
220 return '<span class="t">—</span>'
221 cls = classes.get(value, "")
222 return f'<span class="t {cls}">{escape(value)}</span>'
223 
224 
225def _pr_link(row: BumpRow) -> str:
226 if row.pr_url is None or row.pr_number is None:
227 return '<span class="mut">—</span>'
228 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
229 
230 
231def _short_id(workflow_id: str) -> str:
232 return escape(workflow_id.removeprefix("froot-bump-"))
233 
234 
235# ── header ───────────────────────────────────────────────────────────────────
236def _alive(model: DashboardModel) -> tuple[str, str]:
237 """Global liveness dot + label across every loop."""
238 live = sum(1 for x in model.scan_loops if x.live) + sum(
239 1 for x in model.review_loops if x.live
240 )
241 total = len(model.scan_loops) + len(model.review_loops)
242 if total == 0:
243 return "mute", "no loops configured"
244 kind = "ok" if live == total else ("warn" if live else "bad")
245 return kind, f"{live}/{total} loops live"
246 
247 
248def _header(model: DashboardModel) -> str:
249 kind, label = _alive(model)
250 sources = " ".join(
251 f"{_dot('ok' if s.ok else 'bad')}{escape(s.name)}"
252 for s in model.sources
253 )
254 return (
255 "<header>"
256 '<h1>froot<span class="v mono">gemma4:12b</span></h1>'
257 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
258 f'<span class="hmeta"><span>{sources}</span>'
259 f"<span>{len(model.repos_configured)} repos</span>"
260 f"<span>built {_ago(model.generated_at, model.generated_at)}"
261 " · reload to recompute</span></span>"
262 "</header>"
263 )
264 
265 
266# ── the gate hero (per loop) ─────────────────────────────────────────────────
267def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
268 cls = "bearing armed" if armed else "bearing"
269 return (
270 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
271 f'<span class="bv {kind}">{escape(value)}</span></div>'
272 )
273 
274 
275def _gate_hero(view: LoopView) -> str:
276 t, rel, pr = view.track_record, view.reliability, view.probes
277 earned = sum(1 for g in view.class_gates if g.earned)
278 total = len(view.class_gates)
279 acting = any(r.would_auto_merge for r in view.gate)
280 # The four bearings: rate + defect come from the record; the adversarial
281 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
282 # the merge, so it is shown armed (always-on), not a record figure.
283 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
284 defect_ok = "ok" if not rel.defect_rate else "bad"
285 probe_ok = "ok" if pr.escaped == 0 else "bad"
286 bearings = "".join(
287 (
288 _bearing(
289 "approval rate",
290 _pct(t.merge_rate),
291 rate_ok if t.merge_rate is not None else "mut",
292 ),
293 _bearing(
294 "defect rate",
295 _pct(rel.defect_rate),
296 defect_ok if rel.defect_rate is not None else "mut",
297 ),
298 _bearing(
299 "probe",
300 f"{pr.escaped} escaped" if pr.total else "none",
301 probe_ok if pr.total else "mut",
302 ),
303 _bearing("deep review", "armed", "mut", armed=True),
304 )
305 )
306 if total == 0:
307 node = (
308 '<div class="gatenode"><div class="gl">gate</div>'
309 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
310 )
311 else:
312 node = (
313 f'<div class="gatenode"><div class="gl">earned</div>'
314 f'<div class="gv">{earned}/{total}</div>'
315 '<div class="gs">classes</div></div>'
316 )
317 if acting:
318 out = (
319 '<div class="outcome act"><div class="ot">AUTO-MERGE</div>'
320 '<div class="os">a class is acting now</div></div>'
321 )
322 elif earned:
323 out = (
324 '<div class="outcome act"><div class="ot">EARNED</div>'
325 '<div class="os">acts where allowlisted</div></div>'
326 )
327 else:
328 out = (
329 '<div class="outcome hold"><div class="ot">HOLD</div>'
330 '<div class="os">building the record</div></div>'
331 )
332 flow = (
333 '<div class="gateflow">'
334 f'<div class="bearings">{bearings}</div>'
335 '<div class="flowarrow">&rarr;</div>'
336 f"{node}"
337 '<div class="flowarrow">&rarr;</div>'
338 f"{out}</div>"
339 )
340 caption = (
341 '<p class="caption">A class earns the gate by triangulation: a high '
342 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
343 "evidence. Two further legs guard the live merge — an adversarial "
344 "<b>probe</b> and an independent <b>deep review</b> at merge. "
345 "Auto-merge is allowlist-gated (off by default).</p>"
346 )
347 return (
348 '<div><div class="heroh">Earned autonomy &middot; the gate</div>'
349 f"{flow}{caption}</div>"
350 )
351 
352 
353def _class_table(view: LoopView) -> str:
354 if not view.class_gates:
355 return (
356 '<div><div class="heroh">Classes</div>'
357 '<div class="empty">No classes yet &mdash; a (repo, loop) '
358 "earns the gate from its own track record.</div></div>"
359 )
360 rows = "".join(_class_row(g) for g in view.class_gates)
361 return (
362 '<div><div class="heroh">Per-class standing</div>'
363 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
364 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
365 f"<tbody>{rows}</tbody></table></div>"
366 )
367 
368 
369def _class_row(g: ClassGate) -> str:
370 if g.earned:
371 gate = '<span class="pill ok">earned</span>'
372 else:
373 gate = (
374 f'<span class="pill hold">hold</span> '
375 f'<span class="mut">{escape(g.blocker or "")}</span>'
376 )
377 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
378 budget = (
379 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
380 if g.approvals_per_week
381 else ""
382 )
383 return (
384 f'<tr><td class="mono">{escape(g.repo)}</td>'
385 f'<td class="r">{_pct(g.merge_rate)}</td>'
386 f'<td class="r">{escape(defect)}</td>'
387 f"<td>{gate}</td>"
388 f'<td class="r mut">{escape(budget)}</td></tr>'
389 )
390 
391 
392# ── metric cards (per loop) ──────────────────────────────────────────────────
393def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
394 x = f'<div class="x">{extra}</div>' if extra else ""
395 return (
396 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
397 f'<div class="l">{escape(label)}</div>{x}</div>'
398 )
399 
⋯ 364 lines hidden (lines 400–763)
400 
401def _loop_cards(view: LoopView) -> str:
402 t, v, rel, j, pr = (
403 view.track_record,
404 view.verification,
405 view.reliability,
406 view.judgment,
407 view.probes,
408 )
409 defects = rel.broke + rel.reverted
410 cards = [
411 _card(
412 t.opened,
413 "proposed",
414 f"{t.merged} merged · {t.closed_unmerged} closed",
415 ),
416 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
417 _card(
418 t.open_now,
419 "awaiting you",
420 "open, needs a human",
421 "warn" if t.open_now else "",
422 ),
423 _card(
424 _pct(rel.defect_rate) if rel.determined else "",
425 "defect rate",
426 f"{rel.held} held · {defects} broke",
427 "bad" if defects else "",
428 ),
429 _card(
430 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
431 "CI passed",
432 "the oracle",
433 ),
434 _card(
435 pr.escaped if pr.total else "0",
436 "probes escaped",
437 f"{pr.caught} caught" if pr.total else "no probes yet",
438 "bad" if pr.escaped else "",
439 ),
440 _card(
441 j.clean,
442 "clean verdicts",
443 f"{j.clean_but_failed} mis-judged"
444 if j.clean_but_failed
445 else "judge calibrated",
446 ),
447 _card(rel.held, "merges held", "post-merge, stayed green"),
448 ]
449 return f'<div class="cards">{"".join(cards)}</div>'
450 
451 
452# ── detail tables ────────────────────────────────────────────────────────────
453def _cadence(view: LoopView, now: datetime) -> str:
454 live = sum(1 for s in view.scan_loops if s.live)
455 total = len(view.scan_loops)
456 nxt = ""
457 last = max(
458 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
459 default=None,
460 )
461 if last is not None:
462 due = last + timedelta(seconds=view.scan_interval_seconds)
463 nxt = f" &middot; next {escape(_until(due, now))}"
464 every = round(view.scan_interval_seconds / 3600, 1)
465 return (
466 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
467 f"&middot; every <b>{every}h</b>{nxt}</p>"
468 )
469 
470 
471def _bumps_fold(view: LoopView) -> str:
472 if not view.bumps:
473 return ""
474 rows = "".join(
475 "<tr>"
476 f'<td class="mono">{escape(r.package)}</td>'
477 f'<td class="mono mut">{escape(r.to_version)}</td>'
478 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
479 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
480 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
481 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
482 f"<td>{_pr_link(r)}</td>"
483 "</tr>"
484 for r in view.bumps
485 )
486 return (
487 '<details class="fold"><summary>Bumps '
488 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
489 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
490 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
491 "<th>pr</th></tr></thead>"
492 f"<tbody>{rows}</tbody></table></div></details>"
493 )
494 
495 
496def _failures_fold(view: LoopView) -> str:
497 if not view.failures:
498 return ""
499 rows = "".join(
500 "<tr>"
501 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
502 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
503 f'<td class="mut">{escape(f.reason or "")}</td>'
504 "</tr>"
505 for f in view.failures
506 )
507 return (
508 '<details class="fold"><summary class="bad">Failures '
509 f'<span class="c">{len(view.failures)}</span></summary>'
510 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
511 "<th>kind</th><th>reason</th></tr></thead>"
512 f"<tbody>{rows}</tbody></table></div></details>"
513 )
514 
515 
516# ── panels ───────────────────────────────────────────────────────────────────
517def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
518 queue = _queue_sec(view)
519 return (
520 f'<section class="panel" id="{pid}">'
521 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
522 f"{_loop_cards(view)}"
523 f"{queue}"
524 f"{_cadence(view, now)}"
525 f"{_bumps_fold(view)}{_failures_fold(view)}"
526 "</section>"
527 )
528 
529 
530def _queue_sec(view: LoopView) -> str:
531 if not view.gate:
532 return (
533 '<div class="sec"><div class="sech">Approval queue '
534 '<span class="n">empty</span></div>'
535 '<div class="empty">Nothing awaiting you.</div></div>'
536 )
537 rows = "".join(
538 "<tr>"
539 f'<td class="mono">{escape(r.package)}</td>'
540 f'<td class="mono mut">{escape(r.to_version)}</td>'
541 f"<td>{_queue_badge(r)}</td>"
542 f"<td>{_pr_link(r)}</td>"
543 "</tr>"
544 for r in view.gate
545 )
546 return (
547 '<div class="sec"><div class="sech">Approval queue '
548 f'<span class="n">{len(view.gate)} yours</span></div>'
549 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
550 "<th>gate</th><th>pr</th></tr></thead>"
551 f"<tbody>{rows}</tbody></table></div>"
552 )
553 
554 
555def _queue_badge(row: BumpRow) -> str:
556 if row.would_auto_merge:
557 return '<span class="pill ok">would auto-merge</span>'
558 reason = row.held_reason or "held"
559 return f'<span class="mut">held &middot; {escape(reason)}</span>'
560 
561 
562def _review_panel(model: DashboardModel, pid: str) -> str:
563 r = model.review_record
564 live = sum(1 for x in model.review_loops if x.live)
565 haz = "bad" if r.hazards else ""
566 cards = (
567 '<div class="cards">'
568 f"{_card(r.repos_covered, 'repos covered')}"
569 f"{_card(f'{live}/{len(model.review_loops)}', 'loops live')}"
570 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
571 f"{_card(r.hazards, 'hazards', 'transitive', haz)}"
572 "</div>"
573 )
574 body = (
575 '<div class="empty">No PRs reviewed yet.</div>'
576 if not model.reviews
577 else _reviews_table(model.reviews)
578 )
579 note = (
580 '<p class="caption">The transitive ring — advisory. It '
581 "re-derives each open PR's reachable determinism hazards and "
582 "comments; it never blocks a merge.</p>"
583 )
584 every = round(model.review_interval_seconds / 60, 1)
585 cad = (
586 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
587 f"every <b>{every}m</b></p>"
588 )
589 return (
590 f'<section class="panel" id="{pid}">'
591 '<div class="heroh">Determinism review &middot; the transitive ring'
592 f"</div>{note}{cards}{cad}"
593 f'<div class="sec"><div class="sech">Reviews '
594 f'<span class="n">{len(model.reviews)}</span></div>{body}</div>'
595 "</section>"
596 )
597 
598 
599def _reviews_table(reviews: tuple[ReviewRow, ...]) -> str:
600 rows = "".join(
601 "<tr>"
602 f'<td class="mono">{escape(row.repo)}</td>'
603 f"<td>{_review_pr_link(row)}</td>"
604 f"<td>{_findings_cell(row)}</td>"
605 f'<td class="mono mut">{escape(", ".join(row.rules) or "")}</td>'
606 "</tr>"
607 for row in reviews
608 )
609 return (
610 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
611 "<th>findings</th><th>rules</th></tr></thead>"
612 f"<tbody>{rows}</tbody></table>"
613 )
614 
615 
616def _review_pr_link(row: ReviewRow) -> str:
617 if row.pr_url is None or row.pr_number is None:
618 return '<span class="mut">—</span>'
619 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
620 
621 
622def _findings_cell(row: ReviewRow) -> str:
623 if row.findings == 0:
624 return '<span class="ok">clean</span>'
625 label = "hazard" if row.findings == 1 else "hazards"
626 link = ""
627 if row.comment_url:
628 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
629 return f'<span class="bad">{row.findings} {label}</span>{link}'
630 
631 
632def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
633 tel: RunTelemetry = model.telemetry
634 if not tel.available:
635 body = (
636 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
637 "froot traces in the window.</div>"
638 )
639 else:
640 rows = "".join(
641 "<tr>"
642 f'<td class="mono">{escape(a.name)}</td>'
643 f'<td class="r">{a.count}</td>'
644 f'<td class="r">{a.avg_ms:.0f} ms</td>'
645 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
646 "</tr>"
647 for a in tel.activities
648 )
649 last = f"last {_ago(tel.last_activity, now)}"
650 err = "bad" if tel.error_spans else ""
651 head = (
652 '<div class="cards">'
653 f"{_card(tel.total_spans, 'spans', last)}"
654 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
655 f"{_card(f'{tel.window_days}d', 'window')}</div>"
656 )
657 table = (
658 '<div class="sec"><div class="sech">Activity latency</div>'
659 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
660 "<th>avg</th><th>max</th></tr></thead>"
661 f"<tbody>{rows}</tbody></table></div>"
662 )
663 body = head + table
664 note = (
665 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
666 "(trace-derived, best-effort) — latency per activity across "
667 "every loop.</p>"
668 )
669 return (
670 f'<section class="panel" id="{pid}">'
671 '<div class="heroh">Run telemetry &middot; ClickHouse</div>'
672 f"{note}{body}</section>"
673 )
674 
675 
676# ── tabs + page ──────────────────────────────────────────────────────────────
677def _loop_badge(view: LoopView) -> str:
678 earned = sum(1 for g in view.class_gates if g.earned)
679 if view.class_gates and earned:
680 return f"{earned}/{len(view.class_gates)} earned"
681 if view.track_record.open_now:
682 return f"{view.track_record.open_now} open"
683 return str(view.track_record.opened)
684 
685 
686def page(model: DashboardModel) -> str:
687 """Render the whole dashboard as one self-contained HTML document."""
688 now = model.generated_at
689 # (tab-id, panel-id, label, badge, panel-html)
690 tabs: list[tuple[str, str, str, str, str]] = []
691 for i, view in enumerate(model.bump_loops):
692 pid, tid = f"panel-{i}", f"tab-{i}"
693 tabs.append(
694 (
695 tid,
696 pid,
697 view.title,
698 _loop_badge(view),
699 _loop_panel(view, pid, now),
700 )
701 )
702 tabs.append(
703 (
704 "tab-det",
705 "panel-det",
706 "Determinism review",
707 str(model.review_record.reviewed),
708 _review_panel(model, "panel-det"),
709 )
710 )
711 tabs.append(
712 (
713 "tab-tel",
714 "panel-tel",
715 "Telemetry",
716 "live" if model.telemetry.available else "off",
717 _telemetry_panel(model, "panel-tel", now),
718 )
719 )
720 
721 inputs, labels, panels, rules = [], [], [], []
722 for idx, (tid, pid, label, badge, panel) in enumerate(tabs):
723 checked = " checked" if idx == 0 else ""
724 inputs.append(
725 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
726 )
727 labels.append(
728 f'<label for="{tid}">{escape(label)}'
729 f'<span class="badge">{escape(badge)}</span></label>'
730 )
731 panels.append(panel)
732 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
733 rules.append(
734 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
735 "{color:var(--fg);border-bottom-color:var(--accent)}"
736 )
737 tabcss = "".join(rules)
738 return (
739 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
740 '<meta name="viewport" content="width=device-width,initial-scale=1">'
741 "<title>froot &middot; read-model</title>"
742 f"<style>{_CSS}{tabcss}</style></head><body>"
743 + "".join(inputs)
744 + "<main>"
745 + _header(model)
746 + f'<nav class="tabbar">{"".join(labels)}</nav>'
747 + "".join(panels)
748 + _footer()
749 + "</main></body></html>"
750 )
751 
752 
753def _footer() -> str:
754 return (
755 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
756 "auto-merges only on an allowlisted repo where a class has earned its "
757 "gate (the allowlist is empty by default, so commit authority is none "
758 "until a steward opts in). Trust is earned, narrow to one loop on one "
759 "repo, conditional on its environment "
760 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
761 "Everything here is derived per request from GitHub + Temporal + "
762 "ClickHouse; froot keeps no database.</footer>"
763 )

Tabs + the page shell (no JavaScript)

Tabs are CSS-only: one hidden radio input per tab, and a generated rule #tab:checked ~ main #panel{display:block} shows the active panel. One tab per loop, plus determinism-review and the cross-cutting run-telemetry. The first tab is checked by default.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 685 lines hidden (lines 1–685)
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 BumpRow,
25 ClassGate,
26 DashboardModel,
27 LoopView,
28 ReviewRow,
29 RunTelemetry,
30 )
31 
32_CSS = """
33:root{--fg:#13151a;--mut:#6b7280;--faint:#9aa1ac;--line:#e7e9ee;--bg:#fbfbfc;
34--panel:#fff;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da;
35--accentbg:#ddf0ff;--chip:#f2f4f7;--node:#eef6ff}
36@media(prefers-color-scheme:dark){:root{--fg:#e8eaed;--mut:#9aa1ac;
37--faint:#6b7280;--line:#23262d;--bg:#0a0b0d;--panel:#121419;--ok:#3fb950;
38--warn:#d29922;--bad:#f85149;--accent:#58a6ff;--accentbg:#10263f;
39--chip:#1a1d24;--node:#10263f}}
40*{box-sizing:border-box}
41body{margin:0;background:var(--bg);color:var(--fg);
42font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
43-webkit-font-smoothing:antialiased}
44main{padding:0 clamp(16px,3vw,44px) 64px}
45a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
46.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}
47.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
48.bad{color:var(--bad)}
49/* header */
50header{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 20px;
51padding:22px 0 16px;border-bottom:1px solid var(--line)}
52h1{font-size:19px;margin:0;letter-spacing:-.02em;font-weight:700}
53h1 .v{color:var(--faint);font-weight:400;font-size:13px;margin-left:8px}
54.hstatus{display:flex;align-items:center;gap:7px;font-size:13px}
55.hmeta{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px 16px;
56color:var(--mut);font-size:12px}
57.dot{display:inline-block;width:8px;height:8px;border-radius:50%}
58.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
59.dot.bad{background:var(--bad)}.dot.mute{background:var(--faint)}
60/* tabs (CSS-only) */
61.tabin{position:absolute;opacity:0;pointer-events:none}
62nav.tabbar{display:flex;flex-wrap:wrap;gap:4px;margin:14px 0 0;
63border-bottom:1px solid var(--line)}
64nav.tabbar label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
65padding:9px 15px;font-size:13px;font-weight:600;color:var(--mut);
66border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
67nav.tabbar label:hover{color:var(--fg)}
68nav.tabbar label .badge{font-weight:600;font-size:11px;color:var(--faint);
69background:var(--chip);border-radius:9px;padding:1px 7px}
70.panel{display:none;padding:22px 0 0;animation:fade .15s ease}
71@keyframes fade{from{opacity:.4}to{opacity:1}}
72/* gate hero */
73.hero{display:grid;grid-template-columns:minmax(320px,1.1fr) minmax(280px,1fr);
74gap:18px 26px;align-items:start;margin:0 0 6px}
75@media(max-width:760px){.hero{grid-template-columns:1fr}}
76.heroh{font-size:11px;text-transform:uppercase;letter-spacing:.09em;
77color:var(--mut);font-weight:700;margin:0 0 12px}
78.gateflow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
79.bearings{display:flex;flex-direction:column;gap:6px;min-width:150px}
80.bearing{display:flex;align-items:center;justify-content:space-between;gap:10px;
81background:var(--chip);border-radius:7px;padding:6px 10px;font-size:12px}
82.bearing .bl{color:var(--mut)}
83.bearing .bv{font-weight:600;font-variant-numeric:tabular-nums}
84.bearing.armed .bv{color:var(--faint);font-weight:500}
85.flowarrow{color:var(--faint);font-size:18px;line-height:1}
86.gatenode{flex:0 0 auto;text-align:center;border:2px solid var(--accent);
87background:var(--node);border-radius:12px;padding:12px 16px;min-width:96px}
88.gatenode .gl{font-size:10px;text-transform:uppercase;letter-spacing:.08em;
89color:var(--accent);font-weight:700}
90.gatenode .gv{font-size:22px;font-weight:700;line-height:1.15;
91font-variant-numeric:tabular-nums}
92.gatenode .gs{font-size:11px;color:var(--mut)}
93.outcome{flex:0 0 auto;border-radius:10px;padding:11px 15px;text-align:center;
94border:1px solid var(--line)}
95.outcome .ot{font-size:15px;font-weight:700;letter-spacing:.01em}
96.outcome .os{font-size:11px;color:var(--mut);margin-top:1px}
97.outcome.act{background:color-mix(in srgb,var(--ok) 12%,transparent);
98border-color:color-mix(in srgb,var(--ok) 40%,transparent)}
99.outcome.act .ot{color:var(--ok)}
100.outcome.hold .ot{color:var(--mut)}
101.caption{color:var(--mut);font-size:12px;margin:8px 0 0;line-height:1.45}
102/* class table inside the hero */
103.classes{width:100%;border-collapse:collapse;font-size:12.5px}
104.classes th,.classes td{text-align:left;padding:6px 10px 6px 0;
105border-bottom:1px solid var(--line);vertical-align:baseline}
106.classes th{color:var(--mut);font-weight:600;font-size:10.5px;
107text-transform:uppercase;letter-spacing:.05em}
108.classes td.r{font-variant-numeric:tabular-nums}
109.pill{display:inline-block;font-size:11px;font-weight:600;border-radius:20px;
110padding:1px 9px}
111.pill.ok{color:var(--ok);
112background:color-mix(in srgb,var(--ok) 14%,transparent)}
113.pill.hold{color:var(--mut);background:var(--chip)}
114/* metric cards */
115.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
116gap:10px;margin:22px 0 0}
117.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;
118padding:13px 15px}
119.card .n{font-size:24px;font-weight:700;line-height:1.05;
120font-variant-numeric:tabular-nums}
121.card .l{color:var(--mut);font-size:11.5px;margin-top:3px}
122.card .x{color:var(--faint);font-size:11px;margin-top:5px}
123/* section + detail */
124.sec{margin:26px 0 0}
125.sech{font-size:11px;text-transform:uppercase;letter-spacing:.08em;
126color:var(--mut);font-weight:700;margin:0 0 10px;display:flex;
127align-items:baseline;gap:10px}
128.sech .n{color:var(--faint);font-weight:600;letter-spacing:0}
129details.fold{margin:18px 0 0;border:1px solid var(--line);border-radius:10px;
130background:var(--panel)}
131details.fold>summary{cursor:pointer;list-style:none;padding:11px 15px;
132font-size:12px;font-weight:600;color:var(--fg);display:flex;
133align-items:center;gap:9px}
134details.fold>summary::-webkit-details-marker{display:none}
135details.fold>summary::before{content:"\\25B8";color:var(--faint);font-size:10px}
136details.fold[open]>summary::before{content:"\\25BE"}
137details.fold>summary .c{color:var(--faint);font-weight:600}
138details.fold .body{padding:0 15px 14px}
139table.data{width:100%;border-collapse:collapse;font-size:12.5px}
140table.data th,table.data td{text-align:left;padding:6px 12px 6px 0;
141border-bottom:1px solid var(--line);vertical-align:top}
142table.data th{color:var(--mut);font-weight:600;font-size:10.5px;
143text-transform:uppercase;letter-spacing:.04em}
144table.data td.r{font-variant-numeric:tabular-nums;white-space:nowrap}
145.empty{color:var(--mut);font-size:13px;padding:14px 0;border:1px dashed
146var(--line);border-radius:9px;text-align:center;background:var(--panel)}
147.tags{display:flex;flex-wrap:wrap;gap:6px}
148.t{font-size:11px;border-radius:6px;padding:1px 7px;background:var(--chip);
149color:var(--mut)}
150.t.ok{color:var(--ok)}.t.warn{color:var(--warn)}.t.bad{color:var(--bad)}
151.cad{color:var(--mut);font-size:12px;margin:14px 0 0}
152.cad b{color:var(--fg);font-weight:600}
153footer{margin:40px 0 0;padding-top:16px;border-top:1px solid var(--line);
154color:var(--mut);font-size:12px;line-height:1.65}
155footer b{color:var(--fg);font-weight:600}
156"""
157 
158_CI_CLASS = {
159 "passed": "ok",
160 "failed": "bad",
161 "absent": "mut",
162 "timed_out": "warn",
164_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
165_POST_MERGE_CLASS = {
166 "held": "ok",
167 "broke": "bad",
168 "reverted": "bad",
169 "unknown": "mut",
171_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
172_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
173 
174 
175# ── small formatters ─────────────────────────────────────────────────────────
176def _aware(when: datetime) -> datetime:
177 return when if when.tzinfo else when.replace(tzinfo=UTC)
178 
179 
180def _ago(when: datetime | None, now: datetime) -> str:
181 if when is None:
182 return ""
183 secs = (now - _aware(when)).total_seconds()
184 if secs < 90:
185 return "just now"
186 mins = secs / 60
187 if mins < 90:
188 return f"{round(mins)}m ago"
189 hours = mins / 60
190 if hours < 36:
191 return f"{round(hours)}h ago"
192 return f"{round(hours / 24)}d ago"
193 
194 
195def _until(when: datetime | None, now: datetime) -> str:
196 if when is None:
197 return ""
198 secs = (_aware(when) - now).total_seconds()
199 if secs <= 0:
200 return "due"
201 mins = secs / 60
202 if mins < 90:
203 return f"~{round(mins)}m"
204 hours = mins / 60
205 if hours < 36:
206 return f"~{round(hours)}h"
207 return f"~{round(hours / 24)}d"
208 
209 
210def _pct(rate: float | None) -> str:
211 return "" if rate is None else f"{rate * 100:.0f}%"
212 
213 
214def _dot(kind: str) -> str:
215 return f'<span class="dot {kind}"></span>'
216 
217 
218def _tag(value: str | None, classes: dict[str, str]) -> str:
219 if value is None:
220 return '<span class="t">—</span>'
221 cls = classes.get(value, "")
222 return f'<span class="t {cls}">{escape(value)}</span>'
223 
224 
225def _pr_link(row: BumpRow) -> str:
226 if row.pr_url is None or row.pr_number is None:
227 return '<span class="mut">—</span>'
228 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
229 
230 
231def _short_id(workflow_id: str) -> str:
232 return escape(workflow_id.removeprefix("froot-bump-"))
233 
234 
235# ── header ───────────────────────────────────────────────────────────────────
236def _alive(model: DashboardModel) -> tuple[str, str]:
237 """Global liveness dot + label across every loop."""
238 live = sum(1 for x in model.scan_loops if x.live) + sum(
239 1 for x in model.review_loops if x.live
240 )
241 total = len(model.scan_loops) + len(model.review_loops)
242 if total == 0:
243 return "mute", "no loops configured"
244 kind = "ok" if live == total else ("warn" if live else "bad")
245 return kind, f"{live}/{total} loops live"
246 
247 
248def _header(model: DashboardModel) -> str:
249 kind, label = _alive(model)
250 sources = " ".join(
251 f"{_dot('ok' if s.ok else 'bad')}{escape(s.name)}"
252 for s in model.sources
253 )
254 return (
255 "<header>"
256 '<h1>froot<span class="v mono">gemma4:12b</span></h1>'
257 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
258 f'<span class="hmeta"><span>{sources}</span>'
259 f"<span>{len(model.repos_configured)} repos</span>"
260 f"<span>built {_ago(model.generated_at, model.generated_at)}"
261 " · reload to recompute</span></span>"
262 "</header>"
263 )
264 
265 
266# ── the gate hero (per loop) ─────────────────────────────────────────────────
267def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
268 cls = "bearing armed" if armed else "bearing"
269 return (
270 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
271 f'<span class="bv {kind}">{escape(value)}</span></div>'
272 )
273 
274 
275def _gate_hero(view: LoopView) -> str:
276 t, rel, pr = view.track_record, view.reliability, view.probes
277 earned = sum(1 for g in view.class_gates if g.earned)
278 total = len(view.class_gates)
279 acting = any(r.would_auto_merge for r in view.gate)
280 # The four bearings: rate + defect come from the record; the adversarial
281 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
282 # the merge, so it is shown armed (always-on), not a record figure.
283 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
284 defect_ok = "ok" if not rel.defect_rate else "bad"
285 probe_ok = "ok" if pr.escaped == 0 else "bad"
286 bearings = "".join(
287 (
288 _bearing(
289 "approval rate",
290 _pct(t.merge_rate),
291 rate_ok if t.merge_rate is not None else "mut",
292 ),
293 _bearing(
294 "defect rate",
295 _pct(rel.defect_rate),
296 defect_ok if rel.defect_rate is not None else "mut",
297 ),
298 _bearing(
299 "probe",
300 f"{pr.escaped} escaped" if pr.total else "none",
301 probe_ok if pr.total else "mut",
302 ),
303 _bearing("deep review", "armed", "mut", armed=True),
304 )
305 )
306 if total == 0:
307 node = (
308 '<div class="gatenode"><div class="gl">gate</div>'
309 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
310 )
311 else:
312 node = (
313 f'<div class="gatenode"><div class="gl">earned</div>'
314 f'<div class="gv">{earned}/{total}</div>'
315 '<div class="gs">classes</div></div>'
316 )
317 if acting:
318 out = (
319 '<div class="outcome act"><div class="ot">AUTO-MERGE</div>'
320 '<div class="os">a class is acting now</div></div>'
321 )
322 elif earned:
323 out = (
324 '<div class="outcome act"><div class="ot">EARNED</div>'
325 '<div class="os">acts where allowlisted</div></div>'
326 )
327 else:
328 out = (
329 '<div class="outcome hold"><div class="ot">HOLD</div>'
330 '<div class="os">building the record</div></div>'
331 )
332 flow = (
333 '<div class="gateflow">'
334 f'<div class="bearings">{bearings}</div>'
335 '<div class="flowarrow">&rarr;</div>'
336 f"{node}"
337 '<div class="flowarrow">&rarr;</div>'
338 f"{out}</div>"
339 )
340 caption = (
341 '<p class="caption">A class earns the gate by triangulation: a high '
342 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
343 "evidence. Two further legs guard the live merge — an adversarial "
344 "<b>probe</b> and an independent <b>deep review</b> at merge. "
345 "Auto-merge is allowlist-gated (off by default).</p>"
346 )
347 return (
348 '<div><div class="heroh">Earned autonomy &middot; the gate</div>'
349 f"{flow}{caption}</div>"
350 )
351 
352 
353def _class_table(view: LoopView) -> str:
354 if not view.class_gates:
355 return (
356 '<div><div class="heroh">Classes</div>'
357 '<div class="empty">No classes yet &mdash; a (repo, loop) '
358 "earns the gate from its own track record.</div></div>"
359 )
360 rows = "".join(_class_row(g) for g in view.class_gates)
361 return (
362 '<div><div class="heroh">Per-class standing</div>'
363 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
364 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
365 f"<tbody>{rows}</tbody></table></div>"
366 )
367 
368 
369def _class_row(g: ClassGate) -> str:
370 if g.earned:
371 gate = '<span class="pill ok">earned</span>'
372 else:
373 gate = (
374 f'<span class="pill hold">hold</span> '
375 f'<span class="mut">{escape(g.blocker or "")}</span>'
376 )
377 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
378 budget = (
379 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
380 if g.approvals_per_week
381 else ""
382 )
383 return (
384 f'<tr><td class="mono">{escape(g.repo)}</td>'
385 f'<td class="r">{_pct(g.merge_rate)}</td>'
386 f'<td class="r">{escape(defect)}</td>'
387 f"<td>{gate}</td>"
388 f'<td class="r mut">{escape(budget)}</td></tr>'
389 )
390 
391 
392# ── metric cards (per loop) ──────────────────────────────────────────────────
393def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
394 x = f'<div class="x">{extra}</div>' if extra else ""
395 return (
396 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
397 f'<div class="l">{escape(label)}</div>{x}</div>'
398 )
399 
400 
401def _loop_cards(view: LoopView) -> str:
402 t, v, rel, j, pr = (
403 view.track_record,
404 view.verification,
405 view.reliability,
406 view.judgment,
407 view.probes,
408 )
409 defects = rel.broke + rel.reverted
410 cards = [
411 _card(
412 t.opened,
413 "proposed",
414 f"{t.merged} merged · {t.closed_unmerged} closed",
415 ),
416 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
417 _card(
418 t.open_now,
419 "awaiting you",
420 "open, needs a human",
421 "warn" if t.open_now else "",
422 ),
423 _card(
424 _pct(rel.defect_rate) if rel.determined else "",
425 "defect rate",
426 f"{rel.held} held · {defects} broke",
427 "bad" if defects else "",
428 ),
429 _card(
430 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
431 "CI passed",
432 "the oracle",
433 ),
434 _card(
435 pr.escaped if pr.total else "0",
436 "probes escaped",
437 f"{pr.caught} caught" if pr.total else "no probes yet",
438 "bad" if pr.escaped else "",
439 ),
440 _card(
441 j.clean,
442 "clean verdicts",
443 f"{j.clean_but_failed} mis-judged"
444 if j.clean_but_failed
445 else "judge calibrated",
446 ),
447 _card(rel.held, "merges held", "post-merge, stayed green"),
448 ]
449 return f'<div class="cards">{"".join(cards)}</div>'
450 
451 
452# ── detail tables ────────────────────────────────────────────────────────────
453def _cadence(view: LoopView, now: datetime) -> str:
454 live = sum(1 for s in view.scan_loops if s.live)
455 total = len(view.scan_loops)
456 nxt = ""
457 last = max(
458 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
459 default=None,
460 )
461 if last is not None:
462 due = last + timedelta(seconds=view.scan_interval_seconds)
463 nxt = f" &middot; next {escape(_until(due, now))}"
464 every = round(view.scan_interval_seconds / 3600, 1)
465 return (
466 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
467 f"&middot; every <b>{every}h</b>{nxt}</p>"
468 )
469 
470 
471def _bumps_fold(view: LoopView) -> str:
472 if not view.bumps:
473 return ""
474 rows = "".join(
475 "<tr>"
476 f'<td class="mono">{escape(r.package)}</td>'
477 f'<td class="mono mut">{escape(r.to_version)}</td>'
478 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
479 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
480 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
481 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
482 f"<td>{_pr_link(r)}</td>"
483 "</tr>"
484 for r in view.bumps
485 )
486 return (
487 '<details class="fold"><summary>Bumps '
488 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
489 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
490 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
491 "<th>pr</th></tr></thead>"
492 f"<tbody>{rows}</tbody></table></div></details>"
493 )
494 
495 
496def _failures_fold(view: LoopView) -> str:
497 if not view.failures:
498 return ""
499 rows = "".join(
500 "<tr>"
501 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
502 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
503 f'<td class="mut">{escape(f.reason or "")}</td>'
504 "</tr>"
505 for f in view.failures
506 )
507 return (
508 '<details class="fold"><summary class="bad">Failures '
509 f'<span class="c">{len(view.failures)}</span></summary>'
510 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
511 "<th>kind</th><th>reason</th></tr></thead>"
512 f"<tbody>{rows}</tbody></table></div></details>"
513 )
514 
515 
516# ── panels ───────────────────────────────────────────────────────────────────
517def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
518 queue = _queue_sec(view)
519 return (
520 f'<section class="panel" id="{pid}">'
521 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
522 f"{_loop_cards(view)}"
523 f"{queue}"
524 f"{_cadence(view, now)}"
525 f"{_bumps_fold(view)}{_failures_fold(view)}"
526 "</section>"
527 )
528 
529 
530def _queue_sec(view: LoopView) -> str:
531 if not view.gate:
532 return (
533 '<div class="sec"><div class="sech">Approval queue '
534 '<span class="n">empty</span></div>'
535 '<div class="empty">Nothing awaiting you.</div></div>'
536 )
537 rows = "".join(
538 "<tr>"
539 f'<td class="mono">{escape(r.package)}</td>'
540 f'<td class="mono mut">{escape(r.to_version)}</td>'
541 f"<td>{_queue_badge(r)}</td>"
542 f"<td>{_pr_link(r)}</td>"
543 "</tr>"
544 for r in view.gate
545 )
546 return (
547 '<div class="sec"><div class="sech">Approval queue '
548 f'<span class="n">{len(view.gate)} yours</span></div>'
549 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
550 "<th>gate</th><th>pr</th></tr></thead>"
551 f"<tbody>{rows}</tbody></table></div>"
552 )
553 
554 
555def _queue_badge(row: BumpRow) -> str:
556 if row.would_auto_merge:
557 return '<span class="pill ok">would auto-merge</span>'
558 reason = row.held_reason or "held"
559 return f'<span class="mut">held &middot; {escape(reason)}</span>'
560 
561 
562def _review_panel(model: DashboardModel, pid: str) -> str:
563 r = model.review_record
564 live = sum(1 for x in model.review_loops if x.live)
565 haz = "bad" if r.hazards else ""
566 cards = (
567 '<div class="cards">'
568 f"{_card(r.repos_covered, 'repos covered')}"
569 f"{_card(f'{live}/{len(model.review_loops)}', 'loops live')}"
570 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
571 f"{_card(r.hazards, 'hazards', 'transitive', haz)}"
572 "</div>"
573 )
574 body = (
575 '<div class="empty">No PRs reviewed yet.</div>'
576 if not model.reviews
577 else _reviews_table(model.reviews)
578 )
579 note = (
580 '<p class="caption">The transitive ring — advisory. It '
581 "re-derives each open PR's reachable determinism hazards and "
582 "comments; it never blocks a merge.</p>"
583 )
584 every = round(model.review_interval_seconds / 60, 1)
585 cad = (
586 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
587 f"every <b>{every}m</b></p>"
588 )
589 return (
590 f'<section class="panel" id="{pid}">'
591 '<div class="heroh">Determinism review &middot; the transitive ring'
592 f"</div>{note}{cards}{cad}"
593 f'<div class="sec"><div class="sech">Reviews '
594 f'<span class="n">{len(model.reviews)}</span></div>{body}</div>'
595 "</section>"
596 )
597 
598 
599def _reviews_table(reviews: tuple[ReviewRow, ...]) -> str:
600 rows = "".join(
601 "<tr>"
602 f'<td class="mono">{escape(row.repo)}</td>'
603 f"<td>{_review_pr_link(row)}</td>"
604 f"<td>{_findings_cell(row)}</td>"
605 f'<td class="mono mut">{escape(", ".join(row.rules) or "")}</td>'
606 "</tr>"
607 for row in reviews
608 )
609 return (
610 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
611 "<th>findings</th><th>rules</th></tr></thead>"
612 f"<tbody>{rows}</tbody></table>"
613 )
614 
615 
616def _review_pr_link(row: ReviewRow) -> str:
617 if row.pr_url is None or row.pr_number is None:
618 return '<span class="mut">—</span>'
619 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
620 
621 
622def _findings_cell(row: ReviewRow) -> str:
623 if row.findings == 0:
624 return '<span class="ok">clean</span>'
625 label = "hazard" if row.findings == 1 else "hazards"
626 link = ""
627 if row.comment_url:
628 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
629 return f'<span class="bad">{row.findings} {label}</span>{link}'
630 
631 
632def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
633 tel: RunTelemetry = model.telemetry
634 if not tel.available:
635 body = (
636 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
637 "froot traces in the window.</div>"
638 )
639 else:
640 rows = "".join(
641 "<tr>"
642 f'<td class="mono">{escape(a.name)}</td>'
643 f'<td class="r">{a.count}</td>'
644 f'<td class="r">{a.avg_ms:.0f} ms</td>'
645 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
646 "</tr>"
647 for a in tel.activities
648 )
649 last = f"last {_ago(tel.last_activity, now)}"
650 err = "bad" if tel.error_spans else ""
651 head = (
652 '<div class="cards">'
653 f"{_card(tel.total_spans, 'spans', last)}"
654 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
655 f"{_card(f'{tel.window_days}d', 'window')}</div>"
656 )
657 table = (
658 '<div class="sec"><div class="sech">Activity latency</div>'
659 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
660 "<th>avg</th><th>max</th></tr></thead>"
661 f"<tbody>{rows}</tbody></table></div>"
662 )
663 body = head + table
664 note = (
665 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
666 "(trace-derived, best-effort) — latency per activity across "
667 "every loop.</p>"
668 )
669 return (
670 f'<section class="panel" id="{pid}">'
671 '<div class="heroh">Run telemetry &middot; ClickHouse</div>'
672 f"{note}{body}</section>"
673 )
674 
675 
676# ── tabs + page ──────────────────────────────────────────────────────────────
677def _loop_badge(view: LoopView) -> str:
678 earned = sum(1 for g in view.class_gates if g.earned)
679 if view.class_gates and earned:
680 return f"{earned}/{len(view.class_gates)} earned"
681 if view.track_record.open_now:
682 return f"{view.track_record.open_now} open"
683 return str(view.track_record.opened)
684 
685 
686def page(model: DashboardModel) -> str:
687 """Render the whole dashboard as one self-contained HTML document."""
688 now = model.generated_at
689 # (tab-id, panel-id, label, badge, panel-html)
690 tabs: list[tuple[str, str, str, str, str]] = []
691 for i, view in enumerate(model.bump_loops):
692 pid, tid = f"panel-{i}", f"tab-{i}"
693 tabs.append(
694 (
695 tid,
696 pid,
697 view.title,
698 _loop_badge(view),
699 _loop_panel(view, pid, now),
700 )
701 )
702 tabs.append(
703 (
704 "tab-det",
705 "panel-det",
706 "Determinism review",
707 str(model.review_record.reviewed),
708 _review_panel(model, "panel-det"),
709 )
710 )
711 tabs.append(
712 (
713 "tab-tel",
714 "panel-tel",
715 "Telemetry",
716 "live" if model.telemetry.available else "off",
717 _telemetry_panel(model, "panel-tel", now),
718 )
719 )
720 
721 inputs, labels, panels, rules = [], [], [], []
722 for idx, (tid, pid, label, badge, panel) in enumerate(tabs):
723 checked = " checked" if idx == 0 else ""
724 inputs.append(
725 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
726 )
727 labels.append(
728 f'<label for="{tid}">{escape(label)}'
729 f'<span class="badge">{escape(badge)}</span></label>'
730 )
731 panels.append(panel)
732 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
733 rules.append(
734 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
735 "{color:var(--fg);border-bottom-color:var(--accent)}"
736 )
737 tabcss = "".join(rules)
738 return (
739 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
740 '<meta name="viewport" content="width=device-width,initial-scale=1">'
741 "<title>froot &middot; read-model</title>"
742 f"<style>{_CSS}{tabcss}</style></head><body>"
743 + "".join(inputs)
744 + "<main>"
745 + _header(model)
⋯ 18 lines hidden (lines 746–763)
746 + f'<nav class="tabbar">{"".join(labels)}</nav>'
747 + "".join(panels)
748 + _footer()
749 + "</main></body></html>"
750 )
751 
752 
753def _footer() -> str:
754 return (
755 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
756 "auto-merges only on an allowlisted repo where a class has earned its "
757 "gate (the allowlist is empty by default, so commit authority is none "
758 "until a steward opts in). Trust is earned, narrow to one loop on one "
759 "repo, conditional on its environment "
760 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
761 "Everything here is derived per request from GitHub + Temporal + "
762 "ClickHouse; froot keeps no database.</footer>"
763 )

Each loop panel stacks the hero, a compact metric-card grid, the approval queue, the loop's own scan cadence (telemetry-in-context), and foldable bump/failure detail.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 516 lines hidden (lines 1–516)
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 BumpRow,
25 ClassGate,
26 DashboardModel,
27 LoopView,
28 ReviewRow,
29 RunTelemetry,
30 )
31 
32_CSS = """
33:root{--fg:#13151a;--mut:#6b7280;--faint:#9aa1ac;--line:#e7e9ee;--bg:#fbfbfc;
34--panel:#fff;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da;
35--accentbg:#ddf0ff;--chip:#f2f4f7;--node:#eef6ff}
36@media(prefers-color-scheme:dark){:root{--fg:#e8eaed;--mut:#9aa1ac;
37--faint:#6b7280;--line:#23262d;--bg:#0a0b0d;--panel:#121419;--ok:#3fb950;
38--warn:#d29922;--bad:#f85149;--accent:#58a6ff;--accentbg:#10263f;
39--chip:#1a1d24;--node:#10263f}}
40*{box-sizing:border-box}
41body{margin:0;background:var(--bg);color:var(--fg);
42font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
43-webkit-font-smoothing:antialiased}
44main{padding:0 clamp(16px,3vw,44px) 64px}
45a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
46.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}
47.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
48.bad{color:var(--bad)}
49/* header */
50header{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 20px;
51padding:22px 0 16px;border-bottom:1px solid var(--line)}
52h1{font-size:19px;margin:0;letter-spacing:-.02em;font-weight:700}
53h1 .v{color:var(--faint);font-weight:400;font-size:13px;margin-left:8px}
54.hstatus{display:flex;align-items:center;gap:7px;font-size:13px}
55.hmeta{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px 16px;
56color:var(--mut);font-size:12px}
57.dot{display:inline-block;width:8px;height:8px;border-radius:50%}
58.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
59.dot.bad{background:var(--bad)}.dot.mute{background:var(--faint)}
60/* tabs (CSS-only) */
61.tabin{position:absolute;opacity:0;pointer-events:none}
62nav.tabbar{display:flex;flex-wrap:wrap;gap:4px;margin:14px 0 0;
63border-bottom:1px solid var(--line)}
64nav.tabbar label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;
65padding:9px 15px;font-size:13px;font-weight:600;color:var(--mut);
66border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap}
67nav.tabbar label:hover{color:var(--fg)}
68nav.tabbar label .badge{font-weight:600;font-size:11px;color:var(--faint);
69background:var(--chip);border-radius:9px;padding:1px 7px}
70.panel{display:none;padding:22px 0 0;animation:fade .15s ease}
71@keyframes fade{from{opacity:.4}to{opacity:1}}
72/* gate hero */
73.hero{display:grid;grid-template-columns:minmax(320px,1.1fr) minmax(280px,1fr);
74gap:18px 26px;align-items:start;margin:0 0 6px}
75@media(max-width:760px){.hero{grid-template-columns:1fr}}
76.heroh{font-size:11px;text-transform:uppercase;letter-spacing:.09em;
77color:var(--mut);font-weight:700;margin:0 0 12px}
78.gateflow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
79.bearings{display:flex;flex-direction:column;gap:6px;min-width:150px}
80.bearing{display:flex;align-items:center;justify-content:space-between;gap:10px;
81background:var(--chip);border-radius:7px;padding:6px 10px;font-size:12px}
82.bearing .bl{color:var(--mut)}
83.bearing .bv{font-weight:600;font-variant-numeric:tabular-nums}
84.bearing.armed .bv{color:var(--faint);font-weight:500}
85.flowarrow{color:var(--faint);font-size:18px;line-height:1}
86.gatenode{flex:0 0 auto;text-align:center;border:2px solid var(--accent);
87background:var(--node);border-radius:12px;padding:12px 16px;min-width:96px}
88.gatenode .gl{font-size:10px;text-transform:uppercase;letter-spacing:.08em;
89color:var(--accent);font-weight:700}
90.gatenode .gv{font-size:22px;font-weight:700;line-height:1.15;
91font-variant-numeric:tabular-nums}
92.gatenode .gs{font-size:11px;color:var(--mut)}
93.outcome{flex:0 0 auto;border-radius:10px;padding:11px 15px;text-align:center;
94border:1px solid var(--line)}
95.outcome .ot{font-size:15px;font-weight:700;letter-spacing:.01em}
96.outcome .os{font-size:11px;color:var(--mut);margin-top:1px}
97.outcome.act{background:color-mix(in srgb,var(--ok) 12%,transparent);
98border-color:color-mix(in srgb,var(--ok) 40%,transparent)}
99.outcome.act .ot{color:var(--ok)}
100.outcome.hold .ot{color:var(--mut)}
101.caption{color:var(--mut);font-size:12px;margin:8px 0 0;line-height:1.45}
102/* class table inside the hero */
103.classes{width:100%;border-collapse:collapse;font-size:12.5px}
104.classes th,.classes td{text-align:left;padding:6px 10px 6px 0;
105border-bottom:1px solid var(--line);vertical-align:baseline}
106.classes th{color:var(--mut);font-weight:600;font-size:10.5px;
107text-transform:uppercase;letter-spacing:.05em}
108.classes td.r{font-variant-numeric:tabular-nums}
109.pill{display:inline-block;font-size:11px;font-weight:600;border-radius:20px;
110padding:1px 9px}
111.pill.ok{color:var(--ok);
112background:color-mix(in srgb,var(--ok) 14%,transparent)}
113.pill.hold{color:var(--mut);background:var(--chip)}
114/* metric cards */
115.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
116gap:10px;margin:22px 0 0}
117.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;
118padding:13px 15px}
119.card .n{font-size:24px;font-weight:700;line-height:1.05;
120font-variant-numeric:tabular-nums}
121.card .l{color:var(--mut);font-size:11.5px;margin-top:3px}
122.card .x{color:var(--faint);font-size:11px;margin-top:5px}
123/* section + detail */
124.sec{margin:26px 0 0}
125.sech{font-size:11px;text-transform:uppercase;letter-spacing:.08em;
126color:var(--mut);font-weight:700;margin:0 0 10px;display:flex;
127align-items:baseline;gap:10px}
128.sech .n{color:var(--faint);font-weight:600;letter-spacing:0}
129details.fold{margin:18px 0 0;border:1px solid var(--line);border-radius:10px;
130background:var(--panel)}
131details.fold>summary{cursor:pointer;list-style:none;padding:11px 15px;
132font-size:12px;font-weight:600;color:var(--fg);display:flex;
133align-items:center;gap:9px}
134details.fold>summary::-webkit-details-marker{display:none}
135details.fold>summary::before{content:"\\25B8";color:var(--faint);font-size:10px}
136details.fold[open]>summary::before{content:"\\25BE"}
137details.fold>summary .c{color:var(--faint);font-weight:600}
138details.fold .body{padding:0 15px 14px}
139table.data{width:100%;border-collapse:collapse;font-size:12.5px}
140table.data th,table.data td{text-align:left;padding:6px 12px 6px 0;
141border-bottom:1px solid var(--line);vertical-align:top}
142table.data th{color:var(--mut);font-weight:600;font-size:10.5px;
143text-transform:uppercase;letter-spacing:.04em}
144table.data td.r{font-variant-numeric:tabular-nums;white-space:nowrap}
145.empty{color:var(--mut);font-size:13px;padding:14px 0;border:1px dashed
146var(--line);border-radius:9px;text-align:center;background:var(--panel)}
147.tags{display:flex;flex-wrap:wrap;gap:6px}
148.t{font-size:11px;border-radius:6px;padding:1px 7px;background:var(--chip);
149color:var(--mut)}
150.t.ok{color:var(--ok)}.t.warn{color:var(--warn)}.t.bad{color:var(--bad)}
151.cad{color:var(--mut);font-size:12px;margin:14px 0 0}
152.cad b{color:var(--fg);font-weight:600}
153footer{margin:40px 0 0;padding-top:16px;border-top:1px solid var(--line);
154color:var(--mut);font-size:12px;line-height:1.65}
155footer b{color:var(--fg);font-weight:600}
156"""
157 
158_CI_CLASS = {
159 "passed": "ok",
160 "failed": "bad",
161 "absent": "mut",
162 "timed_out": "warn",
164_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
165_POST_MERGE_CLASS = {
166 "held": "ok",
167 "broke": "bad",
168 "reverted": "bad",
169 "unknown": "mut",
171_STATE_CLASS = {"merged": "ok", "closed": "mut", "open": "warn"}
172_FAIL_CLASS = {"failed": "bad", "terminated": "warn"}
173 
174 
175# ── small formatters ─────────────────────────────────────────────────────────
176def _aware(when: datetime) -> datetime:
177 return when if when.tzinfo else when.replace(tzinfo=UTC)
178 
179 
180def _ago(when: datetime | None, now: datetime) -> str:
181 if when is None:
182 return ""
183 secs = (now - _aware(when)).total_seconds()
184 if secs < 90:
185 return "just now"
186 mins = secs / 60
187 if mins < 90:
188 return f"{round(mins)}m ago"
189 hours = mins / 60
190 if hours < 36:
191 return f"{round(hours)}h ago"
192 return f"{round(hours / 24)}d ago"
193 
194 
195def _until(when: datetime | None, now: datetime) -> str:
196 if when is None:
197 return ""
198 secs = (_aware(when) - now).total_seconds()
199 if secs <= 0:
200 return "due"
201 mins = secs / 60
202 if mins < 90:
203 return f"~{round(mins)}m"
204 hours = mins / 60
205 if hours < 36:
206 return f"~{round(hours)}h"
207 return f"~{round(hours / 24)}d"
208 
209 
210def _pct(rate: float | None) -> str:
211 return "" if rate is None else f"{rate * 100:.0f}%"
212 
213 
214def _dot(kind: str) -> str:
215 return f'<span class="dot {kind}"></span>'
216 
217 
218def _tag(value: str | None, classes: dict[str, str]) -> str:
219 if value is None:
220 return '<span class="t">—</span>'
221 cls = classes.get(value, "")
222 return f'<span class="t {cls}">{escape(value)}</span>'
223 
224 
225def _pr_link(row: BumpRow) -> str:
226 if row.pr_url is None or row.pr_number is None:
227 return '<span class="mut">—</span>'
228 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
229 
230 
231def _short_id(workflow_id: str) -> str:
232 return escape(workflow_id.removeprefix("froot-bump-"))
233 
234 
235# ── header ───────────────────────────────────────────────────────────────────
236def _alive(model: DashboardModel) -> tuple[str, str]:
237 """Global liveness dot + label across every loop."""
238 live = sum(1 for x in model.scan_loops if x.live) + sum(
239 1 for x in model.review_loops if x.live
240 )
241 total = len(model.scan_loops) + len(model.review_loops)
242 if total == 0:
243 return "mute", "no loops configured"
244 kind = "ok" if live == total else ("warn" if live else "bad")
245 return kind, f"{live}/{total} loops live"
246 
247 
248def _header(model: DashboardModel) -> str:
249 kind, label = _alive(model)
250 sources = " ".join(
251 f"{_dot('ok' if s.ok else 'bad')}{escape(s.name)}"
252 for s in model.sources
253 )
254 return (
255 "<header>"
256 '<h1>froot<span class="v mono">gemma4:12b</span></h1>'
257 f'<span class="hstatus">{_dot(kind)}{escape(label)}</span>'
258 f'<span class="hmeta"><span>{sources}</span>'
259 f"<span>{len(model.repos_configured)} repos</span>"
260 f"<span>built {_ago(model.generated_at, model.generated_at)}"
261 " · reload to recompute</span></span>"
262 "</header>"
263 )
264 
265 
266# ── the gate hero (per loop) ─────────────────────────────────────────────────
267def _bearing(label: str, value: str, kind: str, *, armed: bool = False) -> str:
268 cls = "bearing armed" if armed else "bearing"
269 return (
270 f'<div class="{cls}"><span class="bl">{escape(label)}</span>'
271 f'<span class="bv {kind}">{escape(value)}</span></div>'
272 )
273 
274 
275def _gate_hero(view: LoopView) -> str:
276 t, rel, pr = view.track_record, view.reliability, view.probes
277 earned = sum(1 for g in view.class_gates if g.earned)
278 total = len(view.class_gates)
279 acting = any(r.would_auto_merge for r in view.gate)
280 # The four bearings: rate + defect come from the record; the adversarial
281 # probe (canary) is the loop's escaped-count; the deep review runs per-PR at
282 # the merge, so it is shown armed (always-on), not a record figure.
283 rate_ok = "ok" if (t.merge_rate or 0) >= 0.95 else "warn"
284 defect_ok = "ok" if not rel.defect_rate else "bad"
285 probe_ok = "ok" if pr.escaped == 0 else "bad"
286 bearings = "".join(
287 (
288 _bearing(
289 "approval rate",
290 _pct(t.merge_rate),
291 rate_ok if t.merge_rate is not None else "mut",
292 ),
293 _bearing(
294 "defect rate",
295 _pct(rel.defect_rate),
296 defect_ok if rel.defect_rate is not None else "mut",
297 ),
298 _bearing(
299 "probe",
300 f"{pr.escaped} escaped" if pr.total else "none",
301 probe_ok if pr.total else "mut",
302 ),
303 _bearing("deep review", "armed", "mut", armed=True),
304 )
305 )
306 if total == 0:
307 node = (
308 '<div class="gatenode"><div class="gl">gate</div>'
309 '<div class="gv">—</div><div class="gs">no class yet</div></div>'
310 )
311 else:
312 node = (
313 f'<div class="gatenode"><div class="gl">earned</div>'
314 f'<div class="gv">{earned}/{total}</div>'
315 '<div class="gs">classes</div></div>'
316 )
317 if acting:
318 out = (
319 '<div class="outcome act"><div class="ot">AUTO-MERGE</div>'
320 '<div class="os">a class is acting now</div></div>'
321 )
322 elif earned:
323 out = (
324 '<div class="outcome act"><div class="ot">EARNED</div>'
325 '<div class="os">acts where allowlisted</div></div>'
326 )
327 else:
328 out = (
329 '<div class="outcome hold"><div class="ot">HOLD</div>'
330 '<div class="os">building the record</div></div>'
331 )
332 flow = (
333 '<div class="gateflow">'
334 f'<div class="bearings">{bearings}</div>'
335 '<div class="flowarrow">&rarr;</div>'
336 f"{node}"
337 '<div class="flowarrow">&rarr;</div>'
338 f"{out}</div>"
339 )
340 caption = (
341 '<p class="caption">A class earns the gate by triangulation: a high '
342 "<b>approval rate</b> and a low <b>defect rate</b>, over enough "
343 "evidence. Two further legs guard the live merge — an adversarial "
344 "<b>probe</b> and an independent <b>deep review</b> at merge. "
345 "Auto-merge is allowlist-gated (off by default).</p>"
346 )
347 return (
348 '<div><div class="heroh">Earned autonomy &middot; the gate</div>'
349 f"{flow}{caption}</div>"
350 )
351 
352 
353def _class_table(view: LoopView) -> str:
354 if not view.class_gates:
355 return (
356 '<div><div class="heroh">Classes</div>'
357 '<div class="empty">No classes yet &mdash; a (repo, loop) '
358 "earns the gate from its own track record.</div></div>"
359 )
360 rows = "".join(_class_row(g) for g in view.class_gates)
361 return (
362 '<div><div class="heroh">Per-class standing</div>'
363 '<table class="classes"><thead><tr><th>repo</th><th>rate</th>'
364 "<th>defect</th><th>gate</th><th>budget/wk</th></tr></thead>"
365 f"<tbody>{rows}</tbody></table></div>"
366 )
367 
368 
369def _class_row(g: ClassGate) -> str:
370 if g.earned:
371 gate = '<span class="pill ok">earned</span>'
372 else:
373 gate = (
374 f'<span class="pill hold">hold</span> '
375 f'<span class="mut">{escape(g.blocker or "")}</span>'
376 )
377 defect = "" if g.defect_rate is None else _pct(g.defect_rate)
378 budget = (
379 f"{g.reclaim_per_week:.1f}/{g.approvals_per_week:.1f}"
380 if g.approvals_per_week
381 else ""
382 )
383 return (
384 f'<tr><td class="mono">{escape(g.repo)}</td>'
385 f'<td class="r">{_pct(g.merge_rate)}</td>'
386 f'<td class="r">{escape(defect)}</td>'
387 f"<td>{gate}</td>"
388 f'<td class="r mut">{escape(budget)}</td></tr>'
389 )
390 
391 
392# ── metric cards (per loop) ──────────────────────────────────────────────────
393def _card(n: object, label: str, extra: str = "", kind: str = "") -> str:
394 x = f'<div class="x">{extra}</div>' if extra else ""
395 return (
396 f'<div class="card"><div class="n {kind}">{escape(str(n))}</div>'
397 f'<div class="l">{escape(label)}</div>{x}</div>'
398 )
399 
400 
401def _loop_cards(view: LoopView) -> str:
402 t, v, rel, j, pr = (
403 view.track_record,
404 view.verification,
405 view.reliability,
406 view.judgment,
407 view.probes,
408 )
409 defects = rel.broke + rel.reverted
410 cards = [
411 _card(
412 t.opened,
413 "proposed",
414 f"{t.merged} merged · {t.closed_unmerged} closed",
415 ),
416 _card(_pct(t.merge_rate), "approval rate", "the first bearing"),
417 _card(
418 t.open_now,
419 "awaiting you",
420 "open, needs a human",
421 "warn" if t.open_now else "",
422 ),
423 _card(
424 _pct(rel.defect_rate) if rel.determined else "",
425 "defect rate",
426 f"{rel.held} held · {defects} broke",
427 "bad" if defects else "",
428 ),
429 _card(
430 f"{v.passed}/{v.oracle_existed}" if v.oracle_existed else "",
431 "CI passed",
432 "the oracle",
433 ),
434 _card(
435 pr.escaped if pr.total else "0",
436 "probes escaped",
437 f"{pr.caught} caught" if pr.total else "no probes yet",
438 "bad" if pr.escaped else "",
439 ),
440 _card(
441 j.clean,
442 "clean verdicts",
443 f"{j.clean_but_failed} mis-judged"
444 if j.clean_but_failed
445 else "judge calibrated",
446 ),
447 _card(rel.held, "merges held", "post-merge, stayed green"),
448 ]
449 return f'<div class="cards">{"".join(cards)}</div>'
450 
451 
452# ── detail tables ────────────────────────────────────────────────────────────
453def _cadence(view: LoopView, now: datetime) -> str:
454 live = sum(1 for s in view.scan_loops if s.live)
455 total = len(view.scan_loops)
456 nxt = ""
457 last = max(
458 (s.last_tick for s in view.scan_loops if s.last_tick is not None),
459 default=None,
460 )
461 if last is not None:
462 due = last + timedelta(seconds=view.scan_interval_seconds)
463 nxt = f" &middot; next {escape(_until(due, now))}"
464 every = round(view.scan_interval_seconds / 3600, 1)
465 return (
466 f'<p class="cad">Scan loop &middot; <b>{live}/{total}</b> live '
467 f"&middot; every <b>{every}h</b>{nxt}</p>"
468 )
469 
470 
471def _bumps_fold(view: LoopView) -> str:
472 if not view.bumps:
473 return ""
474 rows = "".join(
475 "<tr>"
476 f'<td class="mono">{escape(r.package)}</td>'
477 f'<td class="mono mut">{escape(r.to_version)}</td>'
478 f"<td>{_tag(r.state, _STATE_CLASS)}</td>"
479 f"<td>{_tag(r.verdict, _VERDICT_CLASS)}</td>"
480 f"<td>{_tag(r.ci, _CI_CLASS)}</td>"
481 f"<td>{_tag(r.post_merge, _POST_MERGE_CLASS)}</td>"
482 f"<td>{_pr_link(r)}</td>"
483 "</tr>"
484 for r in view.bumps
485 )
486 return (
487 '<details class="fold"><summary>Bumps '
488 f'<span class="c">{len(view.bumps)}</span></summary><div class="body">'
489 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
490 "<th>state</th><th>verdict</th><th>ci</th><th>post-merge</th>"
491 "<th>pr</th></tr></thead>"
492 f"<tbody>{rows}</tbody></table></div></details>"
493 )
494 
495 
496def _failures_fold(view: LoopView) -> str:
497 if not view.failures:
498 return ""
499 rows = "".join(
500 "<tr>"
501 f'<td class="mono">{_short_id(f.workflow_id)}</td>'
502 f"<td>{_tag(f.kind, _FAIL_CLASS)}</td>"
503 f'<td class="mut">{escape(f.reason or "")}</td>'
504 "</tr>"
505 for f in view.failures
506 )
507 return (
508 '<details class="fold"><summary class="bad">Failures '
509 f'<span class="c">{len(view.failures)}</span></summary>'
510 '<div class="body"><table class="data"><thead><tr><th>bump</th>'
511 "<th>kind</th><th>reason</th></tr></thead>"
512 f"<tbody>{rows}</tbody></table></div></details>"
513 )
514 
515 
516# ── panels ───────────────────────────────────────────────────────────────────
517def _loop_panel(view: LoopView, pid: str, now: datetime) -> str:
518 queue = _queue_sec(view)
519 return (
520 f'<section class="panel" id="{pid}">'
521 f'<div class="hero">{_gate_hero(view)}{_class_table(view)}</div>'
522 f"{_loop_cards(view)}"
523 f"{queue}"
524 f"{_cadence(view, now)}"
525 f"{_bumps_fold(view)}{_failures_fold(view)}"
526 "</section>"
527 )
528 
529 
⋯ 234 lines hidden (lines 530–763)
530def _queue_sec(view: LoopView) -> str:
531 if not view.gate:
532 return (
533 '<div class="sec"><div class="sech">Approval queue '
534 '<span class="n">empty</span></div>'
535 '<div class="empty">Nothing awaiting you.</div></div>'
536 )
537 rows = "".join(
538 "<tr>"
539 f'<td class="mono">{escape(r.package)}</td>'
540 f'<td class="mono mut">{escape(r.to_version)}</td>'
541 f"<td>{_queue_badge(r)}</td>"
542 f"<td>{_pr_link(r)}</td>"
543 "</tr>"
544 for r in view.gate
545 )
546 return (
547 '<div class="sec"><div class="sech">Approval queue '
548 f'<span class="n">{len(view.gate)} yours</span></div>'
549 '<table class="data"><thead><tr><th>package</th><th>&rarr;</th>'
550 "<th>gate</th><th>pr</th></tr></thead>"
551 f"<tbody>{rows}</tbody></table></div>"
552 )
553 
554 
555def _queue_badge(row: BumpRow) -> str:
556 if row.would_auto_merge:
557 return '<span class="pill ok">would auto-merge</span>'
558 reason = row.held_reason or "held"
559 return f'<span class="mut">held &middot; {escape(reason)}</span>'
560 
561 
562def _review_panel(model: DashboardModel, pid: str) -> str:
563 r = model.review_record
564 live = sum(1 for x in model.review_loops if x.live)
565 haz = "bad" if r.hazards else ""
566 cards = (
567 '<div class="cards">'
568 f"{_card(r.repos_covered, 'repos covered')}"
569 f"{_card(f'{live}/{len(model.review_loops)}', 'loops live')}"
570 f"{_card(r.reviewed, 'reviewed', f'{r.flagged} flagged')}"
571 f"{_card(r.hazards, 'hazards', 'transitive', haz)}"
572 "</div>"
573 )
574 body = (
575 '<div class="empty">No PRs reviewed yet.</div>'
576 if not model.reviews
577 else _reviews_table(model.reviews)
578 )
579 note = (
580 '<p class="caption">The transitive ring — advisory. It '
581 "re-derives each open PR's reachable determinism hazards and "
582 "comments; it never blocks a merge.</p>"
583 )
584 every = round(model.review_interval_seconds / 60, 1)
585 cad = (
586 f'<p class="cad">Review loop &middot; <b>{live}</b> live &middot; '
587 f"every <b>{every}m</b></p>"
588 )
589 return (
590 f'<section class="panel" id="{pid}">'
591 '<div class="heroh">Determinism review &middot; the transitive ring'
592 f"</div>{note}{cards}{cad}"
593 f'<div class="sec"><div class="sech">Reviews '
594 f'<span class="n">{len(model.reviews)}</span></div>{body}</div>'
595 "</section>"
596 )
597 
598 
599def _reviews_table(reviews: tuple[ReviewRow, ...]) -> str:
600 rows = "".join(
601 "<tr>"
602 f'<td class="mono">{escape(row.repo)}</td>'
603 f"<td>{_review_pr_link(row)}</td>"
604 f"<td>{_findings_cell(row)}</td>"
605 f'<td class="mono mut">{escape(", ".join(row.rules) or "")}</td>'
606 "</tr>"
607 for row in reviews
608 )
609 return (
610 '<table class="data"><thead><tr><th>repo</th><th>pr</th>'
611 "<th>findings</th><th>rules</th></tr></thead>"
612 f"<tbody>{rows}</tbody></table>"
613 )
614 
615 
616def _review_pr_link(row: ReviewRow) -> str:
617 if row.pr_url is None or row.pr_number is None:
618 return '<span class="mut">—</span>'
619 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
620 
621 
622def _findings_cell(row: ReviewRow) -> str:
623 if row.findings == 0:
624 return '<span class="ok">clean</span>'
625 label = "hazard" if row.findings == 1 else "hazards"
626 link = ""
627 if row.comment_url:
628 link = f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
629 return f'<span class="bad">{row.findings} {label}</span>{link}'
630 
631 
632def _telemetry_panel(model: DashboardModel, pid: str, now: datetime) -> str:
633 tel: RunTelemetry = model.telemetry
634 if not tel.available:
635 body = (
636 '<div class="empty">Unavailable &mdash; ClickHouse off or no '
637 "froot traces in the window.</div>"
638 )
639 else:
640 rows = "".join(
641 "<tr>"
642 f'<td class="mono">{escape(a.name)}</td>'
643 f'<td class="r">{a.count}</td>'
644 f'<td class="r">{a.avg_ms:.0f} ms</td>'
645 f'<td class="r mut">{a.max_ms:.0f} ms</td>'
646 "</tr>"
647 for a in tel.activities
648 )
649 last = f"last {_ago(tel.last_activity, now)}"
650 err = "bad" if tel.error_spans else ""
651 head = (
652 '<div class="cards">'
653 f"{_card(tel.total_spans, 'spans', last)}"
654 f"{_card(tel.error_spans, 'errors', 'in the window', err)}"
655 f"{_card(f'{tel.window_days}d', 'window')}</div>"
656 )
657 table = (
658 '<div class="sec"><div class="sech">Activity latency</div>'
659 '<table class="data"><thead><tr><th>activity</th><th>runs</th>'
660 "<th>avg</th><th>max</th></tr></thead>"
661 f"<tbody>{rows}</tbody></table></div>"
662 )
663 body = head + table
664 note = (
665 '<p class="caption">Cross-cutting run telemetry from ClickHouse '
666 "(trace-derived, best-effort) — latency per activity across "
667 "every loop.</p>"
668 )
669 return (
670 f'<section class="panel" id="{pid}">'
671 '<div class="heroh">Run telemetry &middot; ClickHouse</div>'
672 f"{note}{body}</section>"
673 )
674 
675 
676# ── tabs + page ──────────────────────────────────────────────────────────────
677def _loop_badge(view: LoopView) -> str:
678 earned = sum(1 for g in view.class_gates if g.earned)
679 if view.class_gates and earned:
680 return f"{earned}/{len(view.class_gates)} earned"
681 if view.track_record.open_now:
682 return f"{view.track_record.open_now} open"
683 return str(view.track_record.opened)
684 
685 
686def page(model: DashboardModel) -> str:
687 """Render the whole dashboard as one self-contained HTML document."""
688 now = model.generated_at
689 # (tab-id, panel-id, label, badge, panel-html)
690 tabs: list[tuple[str, str, str, str, str]] = []
691 for i, view in enumerate(model.bump_loops):
692 pid, tid = f"panel-{i}", f"tab-{i}"
693 tabs.append(
694 (
695 tid,
696 pid,
697 view.title,
698 _loop_badge(view),
699 _loop_panel(view, pid, now),
700 )
701 )
702 tabs.append(
703 (
704 "tab-det",
705 "panel-det",
706 "Determinism review",
707 str(model.review_record.reviewed),
708 _review_panel(model, "panel-det"),
709 )
710 )
711 tabs.append(
712 (
713 "tab-tel",
714 "panel-tel",
715 "Telemetry",
716 "live" if model.telemetry.available else "off",
717 _telemetry_panel(model, "panel-tel", now),
718 )
719 )
720 
721 inputs, labels, panels, rules = [], [], [], []
722 for idx, (tid, pid, label, badge, panel) in enumerate(tabs):
723 checked = " checked" if idx == 0 else ""
724 inputs.append(
725 f'<input class="tabin" type="radio" name="tab" id="{tid}"{checked}>'
726 )
727 labels.append(
728 f'<label for="{tid}">{escape(label)}'
729 f'<span class="badge">{escape(badge)}</span></label>'
730 )
731 panels.append(panel)
732 rules.append(f"#{tid}:checked~main #{pid}{{display:block}}")
733 rules.append(
734 f"#{tid}:checked~main nav.tabbar label[for={tid}]"
735 "{color:var(--fg);border-bottom-color:var(--accent)}"
736 )
737 tabcss = "".join(rules)
738 return (
739 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
740 '<meta name="viewport" content="width=device-width,initial-scale=1">'
741 "<title>froot &middot; read-model</title>"
742 f"<style>{_CSS}{tabcss}</style></head><body>"
743 + "".join(inputs)
744 + "<main>"
745 + _header(model)
746 + f'<nav class="tabbar">{"".join(labels)}</nav>'
747 + "".join(panels)
748 + _footer()
749 + "</main></body></html>"
750 )
751 
752 
753def _footer() -> str:
754 return (
755 "<footer><b>Authority envelope.</b> froot opens PRs everywhere and "
756 "auto-merges only on an allowlisted repo where a class has earned its "
757 "gate (the allowlist is empty by default, so commit authority is none "
758 "until a steward opts in). Trust is earned, narrow to one loop on one "
759 "repo, conditional on its environment "
760 '(<span class="mono">gemma4:12b</span>), revocable, and time-expiring. '
761 "Everything here is derived per request from GitHub + Temporal + "
762 "ClickHouse; froot keeps no database.</footer>"
763 )

Verification

Full suite green: 365 tests, mypy clean, ruff clean, both determinism gates green. New tests cover the per-loop partition (each loop its own view; failures attributed by workflow id) and the new render structure (tabs, gate hero + four bearings, per-class table, queue badge, earned pill, determinism + telemetry panels). Rendered with representative data and eyed across all four tabs in light and dark.

tests/test_dashboard_render.py · 393 lines
tests/test_dashboard_render.py393 lines · Python
⋯ 71 lines hidden (lines 1–71)
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 BumpExecution,
11 PrReviewExecution,
12 ReviewExecution,
13 ScanExecution,
15from froot.policy.autonomy import AutonomyPolicy
16 
17NOW = datetime(2026, 6, 3, 12, 0, tzinfo=UTC)
18REPO = "mseeks/revisionist"
19 
20 
21def _model(
22 prs: Sequence[GithubPr] = (),
23 scans: Sequence[ScanExecution] = (),
24 telemetry: tuple[RunTelemetry, str | None] | None = None,
25 reviews: Sequence[ReviewExecution] = (),
26 pr_reviews: Sequence[PrReviewExecution] = (),
27) -> DashboardModel:
28 if telemetry is None:
29 telemetry = (
30 RunTelemetry(
31 available=False,
32 total_spans=0,
33 error_spans=0,
34 last_activity=None,
35 window_days=3,
36 activities=(),
37 ),
38 "off",
39 )
40 return read_model.assemble(
41 now=NOW,
42 repos=(REPO,),
43 scan_interval_seconds=86_400,
44 review_interval_seconds=300,
45 github=(tuple(prs), None),
46 temporal=((tuple(scans), (), tuple(reviews), tuple(pr_reviews)), None),
47 telemetry=telemetry,
48 )
49 
50 
51def _pr(number: int, package: str, state: str, **kw) -> GithubPr:
52 return GithubPr(
53 repo=REPO,
54 number=number,
55 url=f"https://github.com/{REPO}/pull/{number}",
56 package=package,
57 from_version=kw.get("from_version"),
58 to_version=kw.get("to_version", "1.0.0"),
59 verdict=kw.get("verdict"),
60 state=state,
61 opened_at=kw.get("opened", NOW),
62 merged_at=kw.get("merged"),
63 )
64 
65 
66# ── the page shell: self-contained, no JS, tabbed ────────────────────────────
67def test_page_is_a_self_contained_html_document():
68 html = render.page(_model())
69 assert html.startswith("<!doctype html>")
70 assert html.rstrip().endswith("</html>")
71 assert "http://" not in html and "https://" not in html # no links
72 assert "<script" not in html.lower() # CSS-only tabs, no JavaScript
73 
74 
75def test_page_is_tabbed_one_per_loop_plus_determinism_and_telemetry():
76 html = render.page(_model())
77 assert '<nav class="tabbar">' in html
78 # CSS-only tabs: hidden radio inputs drive the panels, no script.
79 assert 'type="radio"' in html and "<script" not in html.lower()
80 for label in ("Dependency-patch", "Determinism review", "Telemetry"):
81 assert label in html
82 # the footer's authority envelope, trimmed of the word-bomb
83 assert "Authority envelope" in html
84 assert "froot" in html
85 
86 
87def test_gate_hero_shows_the_four_bearings_and_a_decision():
88 html = render.page(_model())
89 assert "Earned autonomy" in html and "the gate" in html
90 for bearing in ("approval rate", "defect rate", "probe", "deep review"):
91 assert bearing in html
92 assert "HOLD" in html # no record yet -> the gate holds
93 
94 
95def test_page_renders_track_record_numbers():
96 prs = [
97 _pr(1, "a", "merged", opened=NOW, merged=NOW),
98 _pr(2, "b", "merged", opened=NOW, merged=NOW),
99 ]
⋯ 294 lines hidden (lines 100–393)
100 html = render.page(_model(prs=prs))
101 assert "100%" in html # 2/2 approval rate
102 assert ">2<" in html # the proposed count as a card stat
103 assert "2 merged" in html
104 
105 
106def test_page_links_open_prs_and_lists_them_in_the_queue():
107 prs = [_pr(23, "vitest", "open", to_version="3.2.6")]
108 html = render.page(_model(prs=prs))
109 assert f"https://github.com/{REPO}/pull/23" in html
110 assert "vitest" in html
111 assert "#23" in html
112 
113 
114def test_page_escapes_dynamic_content():
115 evil = "<script>alert(1)</script>"
116 prs = [_pr(1, evil, "merged", opened=NOW, merged=NOW)]
117 html = render.page(_model(prs=prs))
118 assert "<script>alert(1)</script>" not in html
119 assert "&lt;script&gt;" in html
120 
121 
122def test_empty_queue_is_explicit_not_blank():
123 html = render.page(_model())
124 assert "Nothing awaiting you" in html
125 
126 
127# ── telemetry tab ────────────────────────────────────────────────────────────
128def test_telemetry_panel_reports_unavailable_when_off():
129 html = render.page(_model())
130 assert "Unavailable" in html
131 
132 
133def test_telemetry_panel_renders_activity_rows_when_available():
134 telemetry = (
135 RunTelemetry(
136 available=True,
137 total_spans=75,
138 error_spans=10,
139 last_activity=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
140 window_days=3,
141 activities=(
142 ActivityStat(
143 name="open_pull_request",
144 count=14,
145 avg_ms=14162.0,
146 max_ms=55828.0,
147 ),
148 ),
149 ),
150 None,
151 )
152 html = render.page(_model(telemetry=telemetry))
153 assert "open_pull_request" in html
154 assert ">75<" in html and "spans" in html
155 
156 
157def test_scan_cadence_shows_liveness_and_next_due():
158 scans = [
159 ScanExecution(
160 workflow_id="froot-scan-mseeks-revisionist",
161 status="running",
162 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
163 )
164 ]
165 html = render.page(_model(scans=scans))
166 assert REPO in html # the class row carries the repo
167 assert "next" in html # the next-due hint for the live loop
168 
169 
170# ── determinism review tab ───────────────────────────────────────────────────
171def _review(status: str = "running") -> ReviewExecution:
172 return ReviewExecution(
173 workflow_id="froot-review-mseeks-revisionist",
174 status=status,
175 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
176 )
177 
178 
179def _pr_review(
180 pr: int,
181 findings: int,
182 rules: tuple[str, ...],
183 comment: str | None = None,
184) -> PrReviewExecution:
185 return PrReviewExecution(
186 workflow_id=f"froot-pr-review-mseeks-revisionist-{pr}-abc1234def56",
187 status="completed",
188 start=datetime(2026, 6, 3, 6, 0, tzinfo=UTC),
189 close=datetime(2026, 6, 3, 6, 1, tzinfo=UTC),
190 pr_number=pr,
191 head_sha="abc1234def56",
192 findings=findings,
193 rules=rules,
194 comment_url=comment,
195 )
196 
197 
198def test_determinism_tab_has_its_own_treatment_and_empty_state():
199 html = render.page(_model())
200 assert "Determinism review" in html
201 assert "transitive ring" in html
202 assert "No PRs reviewed yet" in html
203 
204 
205def test_determinism_tab_counts_a_live_review_loop():
206 html = render.page(_model(reviews=[_review("running")]))
207 assert "loops live" in html # the liveness card
208 assert ">1<" in html # one repo covered / one loop live
209 
210 
211def test_flagged_review_renders_rule_count_and_comment_link():
212 comment = f"https://github.com/{REPO}/pull/7#issuecomment-1"
213 pr_reviews = [_pr_review(7, 1, ("datetime.datetime.now",), comment=comment)]
214 html = render.page(_model(pr_reviews=pr_reviews))
215 assert "datetime.datetime.now" in html
216 assert "1 hazard" in html
217 assert "#7" in html
218 assert comment in html # the one-click comment link
219 
220 
221def test_clean_review_renders_clean_not_a_hazard():
222 html = render.page(_model(pr_reviews=[_pr_review(8, 0, ())]))
223 assert ">clean<" in html
224 
225 
226# ── the gate: per-class standing + queue badge ───────────────────────────────
227def _model_p(
228 prs: Sequence[GithubPr],
229 bumps: Sequence[BumpExecution],
230 policy: AutonomyPolicy,
231 outcomes: dict[tuple[str, int], str] | None = None,
232) -> DashboardModel:
233 telemetry = (
234 RunTelemetry(
235 available=False,
236 total_spans=0,
237 error_spans=0,
238 last_activity=None,
239 window_days=3,
240 activities=(),
241 ),
242 "off",
243 )
244 return read_model.assemble(
245 now=NOW,
246 repos=(REPO,),
247 policy=policy,
248 scan_interval_seconds=86_400,
249 review_interval_seconds=300,
250 github=(tuple(prs), None),
251 temporal=(((), tuple(bumps), (), ()), None),
252 telemetry=telemetry,
253 outcomes=outcomes,
254 )
255 
256 
257def _clean_green(number: int, package: str) -> tuple[GithubPr, BumpExecution]:
258 opened = datetime(2026, 5, 20, 12, 0, tzinfo=UTC)
259 merged = datetime(2026, 5, 20, 12, 15, tzinfo=UTC)
260 pr = _pr(
261 number, package, "merged", verdict="clean", opened=opened, merged=merged
262 )
263 bump = BumpExecution(
264 workflow_id=f"froot-bump-mseeks-revisionist-{package}",
265 status="completed",
266 start=opened,
267 close=merged,
268 verdict="clean",
269 ci="passed",
270 pr_number=number,
271 repo=REPO,
272 reason=None,
273 )
274 return pr, bump
275 
276 
277def test_class_table_shows_the_unearned_blocker_with_no_history():
278 html = render.page(_model())
279 assert "Per-class standing" in html
280 assert "only 0/5 decided recently" in html # the honest blocker
281 
282 
283def test_hero_says_no_classes_when_no_repos_configured():
284 model = read_model.assemble(
285 now=NOW,
286 repos=(),
287 scan_interval_seconds=86_400,
288 review_interval_seconds=300,
289 github=((), None),
290 temporal=(((), (), (), ()), None),
291 telemetry=(
292 RunTelemetry(
293 available=False,
294 total_spans=0,
295 error_spans=0,
296 last_activity=None,
297 window_days=3,
298 activities=(),
299 ),
300 "off",
301 ),
302 )
303 assert "No classes yet" in render.page(model)
304 
305 
306def test_queue_badge_holds_open_pr_with_substantive_reason():
307 prs = [_pr(23, "vitest", "open", verdict="clean", opened=NOW)]
308 html = render.page(_model_p(prs, [], AutonomyPolicy()))
309 assert "held" in html
310 assert "class not earned" in html
311 
312 
313def test_class_earned_pill_and_budget():
314 pairs = [_clean_green(n, f"pkg{n}") for n in (1, 2, 3, 4, 5)]
315 prs = [p for p, _ in pairs]
316 bumps = [b for _, b in pairs]
317 policy = AutonomyPolicy(min_decided=3, allowlisted_repos=frozenset({REPO}))
318 held = {(REPO, n): "held" for n in (1, 2, 3, 4, 5)} # defect bearing clean
319 html = render.page(_model_p(prs, bumps, policy, outcomes=held))
320 assert ">earned<" in html # the class cleared its gate (the pill)
321 assert "budget/wk" in html # the budget column
322 
323 
324def test_queue_badge_would_auto_merge_on_earned_allowlisted_class():
325 pairs = [_clean_green(n, f"pkg{n}") for n in (1, 2, 3)]
326 prs = [p for p, _ in pairs]
327 bumps = [b for _, b in pairs]
328 _, open_bump = _clean_green(9, "axios")
329 prs.append(_pr(9, "axios", "open", verdict="clean", opened=NOW))
330 bumps.append(open_bump)
331 policy = AutonomyPolicy(min_decided=3, allowlisted_repos=frozenset({REPO}))
332 held = {(REPO, n): "held" for n in (1, 2, 3)} # clear the defect bearing
333 html = render.page(_model_p(prs, bumps, policy, outcomes=held))
334 assert "would auto-merge" in html
335 
336 
337# ── reliability + probes surface in the loop's cards / detail ────────────────
338def _model_outcomes(
339 prs: Sequence[GithubPr], outcomes: dict[tuple[str, int], str]
340) -> DashboardModel:
341 telemetry = (
342 RunTelemetry(
343 available=False,
344 total_spans=0,
345 error_spans=0,
346 last_activity=None,
347 window_days=3,
348 activities=(),
349 ),
350 "off",
351 )
352 return read_model.assemble(
353 now=NOW,
354 repos=(REPO,),
355 scan_interval_seconds=86_400,
356 review_interval_seconds=300,
357 github=(tuple(prs), None),
358 temporal=(((), (), (), ()), None),
359 telemetry=telemetry,
360 outcomes=outcomes,
361 reliability_window_days=90,
362 )
363 
364 
365def test_defect_rate_card_and_post_merge_tags():
366 prs = [
367 _pr(1, "a", "merged", opened=NOW, merged=NOW),
368 _pr(2, "b", "merged", opened=NOW, merged=NOW),
369 ]
370 outcomes = {(REPO, 1): "held", (REPO, 2): "broke"}
371 html = render.page(_model_outcomes(prs, outcomes))
372 assert "defect rate" in html
373 assert "50%" in html # 1 of 2 determined was a defect
374 assert ">held<" in html and ">broke<" in html # the post-merge tags
375 
376 
377def test_canary_escape_shows_in_probes_card_and_segregated():
378 # A merged canary (to 99.99.99) is a guardrail hole: the probes card counts
379 # the escape, and the canary stays out of the genuine bumps.
380 prs = [
381 _pr(7, "evil", "merged", to_version="99.99.99", opened=NOW, merged=NOW)
382 ]
383 html = render.page(_model_outcomes(prs, {}))
384 assert "probes escaped" in html
385 # the canary is not a real bump -> the track record stays at zero proposed
386 assert "evil" not in html
387 
388 
389def test_bumps_fold_lists_bumps_with_post_merge_column():
390 prs = [_pr(1, "a", "merged", opened=NOW, merged=NOW)]
391 html = render.page(_model_outcomes(prs, {(REPO, 1): "reverted"}))
392 assert "post-merge" in html # the bumps-table column
393 assert "reverted" in html # the per-row post-merge tag