Overview — a second loop on the page

The read-model dashboard was single-loop: it showed the dependency-patch scan/bump loop and nothing else. This change adds the determinism reviewer (the transitive ring) to the same page, derived the same way — Temporal as the recent ledger, every figure computed in the read-model and unit-tested apart from any I/O.

The view model

Three new frozen types, each a twin of an existing one: ReviewLoop (liveness, like ScanLoop), ReviewRecord (the headline, like TrackRecord), and ReviewRow (one per-PR review, like BumpRow). They carry already-computed numbers, so the renderer stays a projection.

ReviewLoop · ReviewRow · ReviewRecord · 308 lines
ReviewLoop · ReviewRow · ReviewRecord308 lines · Python
⋯ 204 lines hidden (lines 1–204)
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 status: The current scan workflow status (``running`` /
38 ``continued_as_new`` / ``terminated`` / ``none`` / ...), lowercased.
39 live: True when the loop is actively self-scheduling (running / CAN).
40 last_tick: When the current scan execution started (≈ the last tick),
41 or ``None`` if no scan workflow exists for the repo.
42 """
43 
44 repo: str
45 status: str
46 live: bool
47 last_tick: datetime | None
48 
49 
50class BumpRow(Frozen):
51 """One proposed dependency bump, joined across GitHub and Temporal.
52 
53 GitHub is authoritative for the outcome (state / timestamps / PR); Temporal
54 enriches with the model verdict and the CI reading while it is still in the
55 7-day window, with the PR body as the durable fallback for the verdict.
56 
57 Attributes:
58 repo: The ``owner/name`` slug.
59 package: The bumped dependency.
60 from_version: The version bumped from, if known.
61 to_version: The version bumped to.
62 state: The GitHub PR state — ``open`` / ``merged`` / ``closed``.
63 verdict: The changelog verdict (``clean`` / ``risky`` / ``unknown``), or
64 ``None`` when neither Temporal nor the PR body yields one.
65 ci: The terminal CI reading (``passed`` / ``failed`` / ``absent`` /
66 ``timed_out``), or ``None`` when only durable GitHub data remains.
67 pr_number: The PR number, if a PR exists.
68 pr_url: The PR URL, if a PR exists.
69 opened_at: When the PR was opened.
70 merged_at: When the PR was merged, if it was.
71 ttm_minutes: Time-to-merge in minutes (merged - opened), if merged.
72 age_hours: Age in hours for a still-open PR (now - opened), or ``None``.
73 """
74 
75 repo: str
76 package: str
77 from_version: str | None
78 to_version: str
79 state: str
80 verdict: str | None
81 ci: str | None
82 pr_number: int | None
83 pr_url: str | None
84 opened_at: datetime | None
85 merged_at: datetime | None
86 ttm_minutes: float | None
87 age_hours: float | None
88 
89 
90class Failure(Frozen):
91 """A bump loop that did not close — the honest friction signal.
92 
93 Attributes:
94 workflow_id: The Temporal workflow id (encodes repo/package/target).
95 kind: ``terminated`` or ``failed``.
96 reason: The human-readable termination/failure reason, if recovered.
97 when: When the workflow closed, if known.
98 """
99 
100 workflow_id: str
101 kind: str
102 reason: str | None
103 when: datetime | None
104 
105 
106class ActivityStat(Frozen):
107 """Latency of one activity stage, from traces (run-telemetry enrichment).
108 
109 Attributes:
110 name: The activity name (``scan_candidates``, ``open_pull_request``).
111 count: How many executions in the window.
112 avg_ms: Mean duration in milliseconds.
113 max_ms: Max duration in milliseconds.
114 """
115 
116 name: str
117 count: int
118 avg_ms: float
119 max_ms: float
120 
121 
122class RunTelemetry(Frozen):
123 """Trace-derived run telemetry from ClickHouse, or an unavailable marker.
124 
125 Attributes:
126 available: True when ClickHouse answered with froot traces.
127 total_spans: Total froot spans in the window.
128 error_spans: Spans that ended in an error.
129 last_activity: The most recent froot span timestamp.
130 window_days: The look-back window the figures cover.
131 activities: Per-stage latency rows.
132 """
133 
134 available: bool
135 total_spans: int
136 error_spans: int
137 last_activity: datetime | None
138 window_days: int
139 activities: tuple[ActivityStat, ...]
140 
141 
142class TrackRecord(Frozen):
143 """The reputation headline, derived from GitHub PR outcomes.
144 
145 Attributes:
146 opened: Total bumps froot has proposed.
147 merged: How many a human merged.
148 closed_unmerged: How many were closed without merging.
149 open_now: How many are still awaiting a decision.
150 merge_rate: ``merged / (merged + closed_unmerged)``, or ``None`` if none
151 have been decided yet.
152 median_ttm_minutes: Median time-to-merge across merged PRs, or ``None``.
153 """
154 
155 opened: int
156 merged: int
157 closed_unmerged: int
158 open_now: int
159 merge_rate: float | None
160 median_ttm_minutes: float | None
161 
162 
163class Verification(Frozen):
164 """The CI-oracle breakdown — kept honest about whether an oracle existed.
165 
166 Attributes:
167 passed: Bumps whose CI went green.
168 failed: Bumps whose CI went red.
169 absent: Bumps where no CI check existed (no oracle).
170 timed_out: Bumps where froot stopped waiting on CI.
171 unknown: Bumps with no recoverable CI reading (aged out of Temporal).
172 oracle_existed: Bumps where a real oracle reported (passed + failed).
173 with_reading: Bumps with any CI reading at all (excludes ``unknown``).
174 """
175 
176 passed: int
177 failed: int
178 absent: int
179 timed_out: int
180 unknown: int
181 oracle_existed: int
182 with_reading: int
183 
184 
185class Judgment(Frozen):
186 """The model's changelog-verdict mix and its calibration against CI.
187 
188 Attributes:
189 clean: Verdicts of ``clean``.
190 risky: Verdicts of ``risky``.
191 unknown: Verdicts of ``unknown``.
192 none: Bumps with no recoverable verdict.
193 clean_but_failed: ``clean`` verdicts whose CI failed (mis-judged).
194 flagged_but_passed: ``risky``/``unknown`` verdicts whose CI passed.
195 """
196 
197 clean: int
198 risky: int
199 unknown: int
200 none: int
201 clean_but_failed: int
202 flagged_but_passed: int
203 
204 
205class ReviewLoop(Frozen):
206 """Liveness of one repo's determinism-review loop (the transitive ring).
207 
208 Attributes:
209 repo: The ``owner/name`` slug this loop reviews.
210 status: The review workflow status (``running`` /
211 ``continued_as_new`` / ``terminated`` / ...), lowercased.
212 live: True when the loop is actively self-scheduling.
213 last_tick: When the current review execution started (≈ the last tick),
214 or ``None`` if no review workflow exists for the repo.
215 """
216 
217 repo: str
218 status: str
219 live: bool
220 last_tick: datetime | None
221 
222 
223class ReviewRow(Frozen):
224 """One per-PR determinism review, from its ``PrReviewWorkflow`` result.
225 
226 Attributes:
227 repo: The ``owner/name`` slug the PR belongs to.
228 pr_number: The reviewed PR number, if known.
229 pr_url: The PR's web URL, if it can be formed.
230 head_sha: The head commit the review ran against.
231 findings: How many transitive hazards the review surfaced.
232 rules: The distinct banned calls flagged (``datetime.datetime.now``…).
233 comment_url: The advisory comment's URL, if one was posted.
234 status: The review workflow status (``completed`` / ``running`` / ...).
235 reviewed_at: When the review closed, or started if still running.
236 """
237 
238 repo: str
239 pr_number: int | None
240 pr_url: str | None
241 head_sha: str | None
242 findings: int
243 rules: tuple[str, ...]
244 comment_url: str | None
245 status: str
246 reviewed_at: datetime | None
247 
248 
249class ReviewRecord(Frozen):
250 """The determinism reviewer's headline, derived from completed reviews.
251 
252 The hazard-resolved rate (was a flagged hazard gone on a later commit?) is
253 a later addition — it needs accumulated cross-commit history, so it is not
254 here yet.
255 
256 Attributes:
257 reviewed: Completed per-PR reviews in the recent window.
258 flagged: Reviews that surfaced at least one hazard.
259 clean: Reviews that surfaced none.
260 hazards: Total hazards surfaced across all reviews.
261 repos_covered: Distinct repos with a live review loop.
262 """
263 
264 reviewed: int
265 flagged: int
266 clean: int
267 hazards: int
268 repos_covered: int
269 
⋯ 39 lines hidden (lines 270–308)
270 
271class DashboardModel(Frozen):
272 """The whole 10,000ft view, fully derived and ready to render.
273 
274 Attributes:
275 generated_at: When this view was assembled (UTC).
276 repos_configured: The repos froot is pointed at (``FROOT_REPOS``).
277 scan_interval_seconds: The configured gap between scan ticks.
278 sources: Per-source health for this request.
279 scan_loops: Liveness of each repo's scan schedule.
280 track_record: The reputation headline.
281 verification: The CI-oracle breakdown.
282 judgment: The model-verdict mix and calibration.
283 gate: Open PRs awaiting a human, the freshest last.
284 bumps: Every proposed bump, newest first (the detail behind the stats).
285 failures: Bump loops that did not close.
286 review_interval_seconds: The configured gap between review poll ticks.
287 review_loops: Liveness of each Temporal repo's determinism-review loop.
288 review_record: The determinism reviewer's headline.
289 reviews: Every per-PR determinism review, newest first.
290 telemetry: Trace-derived run telemetry (best-effort).
291 """
292 
293 generated_at: datetime
294 repos_configured: tuple[str, ...]
295 scan_interval_seconds: int
296 sources: tuple[SourceHealth, ...]
297 scan_loops: tuple[ScanLoop, ...]
298 track_record: TrackRecord
299 verification: Verification
300 judgment: Judgment
301 gate: tuple[BumpRow, ...]
302 bumps: tuple[BumpRow, ...]
303 failures: tuple[Failure, ...]
304 review_interval_seconds: int
305 review_loops: tuple[ReviewLoop, ...]
306 review_record: ReviewRecord
307 reviews: tuple[ReviewRow, ...]
308 telemetry: RunTelemetry

Reading the review out of Temporal

The Temporal reader already lists ScanWorkflow/BumpWorkflow; now it also lists ReviewWorkflow (loop liveness) and PrReviewWorkflow. For a completed per-PR review it decodes the PrReviewResult — the finding count, the flagged rules, and the advisory comment URL — defensively, so a bad decode is never fatal to the page.

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

Deriving, and attributing each review to a repo

PrReviewResult doesn't carry the repo slug, but the workflow id does (froot-pr-review-<owner>-<name>-<pr>-<sha>). The read-model matches each review id against the configured repos by the id prefix it would have — longest match wins — exactly how scan/bump join to repos by id. Review loops are listed only for repos that actually have one (reviews are scoped to the Temporal repos), not as dead rows.

_pr_review_prefix · _attribute_repo · _review_loops · 453 lines
_pr_review_prefix · _attribute_repo · _review_loops453 lines · Python
⋯ 270 lines hidden (lines 1–270)
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 DashboardModel,
19 Failure,
20 Judgment,
21 ReviewLoop,
22 ReviewRecord,
23 ReviewRow,
24 RunTelemetry,
25 ScanLoop,
26 SourceHealth,
27 TrackRecord,
28 Verification,
30from froot.domain.repo import RepoRef, TargetRepo
31from froot.policy.naming import review_workflow_id, scan_workflow_id
32from froot.result import Ok
33 
34if TYPE_CHECKING:
35 from datetime import datetime
36 
37 from froot.dashboard.github_source import GithubPr
38 from froot.dashboard.temporal_source import (
39 BumpExecution,
40 PrReviewExecution,
41 ReviewExecution,
42 ScanExecution,
43 )
44 
45_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
46# Every way a bump can end without closing cleanly — all belong in the honest
47# Failures panel, not silently dropped.
48_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
49 
50 
51def _median(values: list[float]) -> float | None:
52 """The median of ``values``, or ``None`` if empty (pure)."""
53 if not values:
54 return None
55 ordered = sorted(values)
56 mid = len(ordered) // 2
57 if len(ordered) % 2 == 1:
58 return ordered[mid]
59 return (ordered[mid - 1] + ordered[mid]) / 2
60 
61 
62def _scan_id(repo: str) -> str | None:
63 """The deterministic scan-loop id for an ``owner/name`` slug, if valid."""
64 match RepoRef.parse(repo):
65 case Ok(ref):
66 return scan_workflow_id(TargetRepo(repo=ref))
67 case _:
68 return None
69 
70 
71def _scan_loops(
72 repos: tuple[str, ...], scans: tuple[ScanExecution, ...]
73) -> tuple[ScanLoop, ...]:
74 """One liveness row per configured repo (latest execution wins)."""
75 by_id: dict[str, ScanExecution] = {}
76 for scan in scans:
77 current = by_id.get(scan.workflow_id)
78 if current is None or _newer(scan.start, current.start):
79 by_id[scan.workflow_id] = scan
80 loops: list[ScanLoop] = []
81 for repo in repos:
82 scan_id = _scan_id(repo)
83 execution = by_id.get(scan_id) if scan_id is not None else None
84 if execution is None:
85 loops.append(
86 ScanLoop(repo=repo, status="none", live=False, last_tick=None)
87 )
88 else:
89 loops.append(
90 ScanLoop(
91 repo=repo,
92 status=execution.status,
93 live=execution.status in _LIVE_STATUSES,
94 last_tick=execution.start,
95 )
96 )
97 return tuple(loops)
98 
99 
100def _newer(a: datetime | None, b: datetime | None) -> bool:
101 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
102 if a is None:
103 return False
104 if b is None:
105 return True
106 return a > b
107 
108 
109def _bump_rows(
110 now: datetime,
111 prs: tuple[GithubPr, ...],
112 bumps: tuple[BumpExecution, ...],
113) -> tuple[BumpRow, ...]:
114 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number."""
115 # Keyed on (repo, PR number), not the bare number: Temporal lists every
116 # repo's bumps in the namespace, so two repos that each have a PR #N would
117 # otherwise cross-attribute one's verdict/CI onto the other's row.
118 by_pr: dict[tuple[str, int], BumpExecution] = {
119 (bump.repo, bump.pr_number): bump
120 for bump in bumps
121 if bump.repo is not None and bump.pr_number is not None
122 }
123 rows: list[BumpRow] = []
124 for pr in prs:
125 execution = by_pr.get((pr.repo, pr.number))
126 verdict = (execution.verdict if execution else None) or pr.verdict
127 ci = execution.ci if execution else None
128 ttm = _minutes_between(pr.opened_at, pr.merged_at)
129 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
130 rows.append(
131 BumpRow(
132 repo=pr.repo,
133 package=pr.package or "?",
134 from_version=pr.from_version,
135 to_version=pr.to_version or "?",
136 state=pr.state,
137 verdict=verdict,
138 ci=ci,
139 pr_number=pr.number,
140 pr_url=pr.url,
141 opened_at=pr.opened_at,
142 merged_at=pr.merged_at,
143 ttm_minutes=ttm,
144 age_hours=age,
145 )
146 )
147 rows.sort(key=_opened_sort_key, reverse=True)
148 return tuple(rows)
149 
150 
151def _opened_sort_key(row: BumpRow) -> float:
152 """Sort key putting the most recently opened PR first (unknown last)."""
153 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
154 
155 
156def _minutes_between(
157 start: datetime | None, end: datetime | None
158) -> float | None:
159 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
160 if start is None or end is None:
161 return None
162 return round((end - start).total_seconds() / 60, 1)
163 
164 
165def _hours_between(
166 start: datetime | None, end: datetime | None
167) -> float | None:
168 """Hours from ``start`` to ``end``, or ``None``."""
169 if start is None or end is None:
170 return None
171 return round((end - start).total_seconds() / 3600, 1)
172 
173 
174def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
175 """Counts, merge rate, and median time-to-merge from the bump rows."""
176 merged = [r for r in rows if r.state == "merged"]
177 closed = sum(1 for r in rows if r.state == "closed")
178 open_now = sum(1 for r in rows if r.state == "open")
179 decided = len(merged) + closed
180 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
181 return TrackRecord(
182 opened=len(rows),
183 merged=len(merged),
184 closed_unmerged=closed,
185 open_now=open_now,
186 merge_rate=(len(merged) / decided) if decided else None,
187 median_ttm_minutes=_median(ttms),
188 )
189 
190 
191def _verification(rows: tuple[BumpRow, ...]) -> Verification:
192 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
193 passed = sum(1 for r in rows if r.ci == "passed")
194 failed = sum(1 for r in rows if r.ci == "failed")
195 absent = sum(1 for r in rows if r.ci == "absent")
196 timed_out = sum(1 for r in rows if r.ci == "timed_out")
197 unknown = sum(1 for r in rows if r.ci is None)
198 return Verification(
199 passed=passed,
200 failed=failed,
201 absent=absent,
202 timed_out=timed_out,
203 unknown=unknown,
204 oracle_existed=passed + failed,
205 with_reading=passed + failed + absent + timed_out,
206 )
207 
208 
209def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
210 """The verdict mix plus the two calibration cells worth flagging."""
211 clean = sum(1 for r in rows if r.verdict == "clean")
212 risky = sum(1 for r in rows if r.verdict == "risky")
213 unknown = sum(1 for r in rows if r.verdict == "unknown")
214 none = sum(1 for r in rows if r.verdict is None)
215 clean_but_failed = sum(
216 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
217 )
218 flagged_but_passed = sum(
219 1
220 for r in rows
221 if r.verdict in ("risky", "unknown") and r.ci == "passed"
222 )
223 return Judgment(
224 clean=clean,
225 risky=risky,
226 unknown=unknown,
227 none=none,
228 clean_but_failed=clean_but_failed,
229 flagged_but_passed=flagged_but_passed,
230 )
231 
232 
233def _gate(rows: tuple[BumpRow, ...]) -> tuple[BumpRow, ...]:
234 """Open PRs awaiting a human, the most-aged first (the freshest last)."""
235 open_rows = [r for r in rows if r.state == "open"]
236 open_rows.sort(
237 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
238 reverse=True,
239 )
240 return tuple(open_rows)
241 
242 
243def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
244 """Bump loops that did not close, newest first."""
245 failures = [
246 Failure(
247 workflow_id=bump.workflow_id,
248 kind=bump.status,
249 reason=bump.reason,
250 when=bump.close,
251 )
252 for bump in bumps
253 if bump.status in _FAILURE_STATUSES
254 ]
255 failures.sort(
256 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
257 reverse=True,
258 )
259 return tuple(failures)
260 
261 
262def _review_id(repo: str) -> str | None:
263 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
264 match RepoRef.parse(repo):
265 case Ok(ref):
266 return review_workflow_id(TargetRepo(repo=ref))
267 case _:
268 return None
269 
270 
271def _pr_review_prefix(repo: str) -> str | None:
272 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
273 review_id = _review_id(repo)
274 if review_id is None:
275 return None
276 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
277 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
278 
279 
280def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
281 """The configured repo a per-PR-review id belongs to (longest prefix)."""
282 best: str | None = None
283 best_len = -1
284 for repo in repos:
285 prefix = _pr_review_prefix(repo)
286 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
287 best, best_len = repo, len(prefix)
288 return best
289 
290 
291def _review_loops(
292 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
293) -> tuple[ReviewLoop, ...]:
294 """One liveness row per repo that actually has a review loop.
295 
296 Reviews are scoped to the Temporal repos, so a configured npm repo with no
297 review loop is omitted rather than shown as a dead one.
298 """
299 by_id: dict[str, ReviewExecution] = {}
300 for review in reviews:
301 current = by_id.get(review.workflow_id)
302 if current is None or _newer(review.start, current.start):
303 by_id[review.workflow_id] = review
304 loops: list[ReviewLoop] = []
305 for repo in repos:
306 review_id = _review_id(repo)
307 execution = by_id.get(review_id) if review_id is not None else None
308 if execution is None:
309 continue
310 loops.append(
311 ReviewLoop(
312 repo=repo,
313 status=execution.status,
314 live=execution.status in _LIVE_STATUSES,
315 last_tick=execution.start,
316 )
317 )
318 return tuple(loops)
⋯ 135 lines hidden (lines 319–453)
319 
320 
321def _review_rows(
322 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
323) -> tuple[ReviewRow, ...]:
324 """Project each per-PR review into a row, newest review first."""
325 rows: list[ReviewRow] = []
326 for execution in pr_reviews:
327 repo = _attribute_repo(execution.workflow_id, repos)
328 pr_url = (
329 f"https://github.com/{repo}/pull/{execution.pr_number}"
330 if repo is not None and execution.pr_number is not None
331 else None
332 )
333 rows.append(
334 ReviewRow(
335 repo=repo or "?",
336 pr_number=execution.pr_number,
337 pr_url=pr_url,
338 head_sha=execution.head_sha,
339 findings=execution.findings,
340 rules=execution.rules,
341 comment_url=execution.comment_url,
342 status=execution.status,
343 reviewed_at=execution.close or execution.start,
344 )
345 )
346 rows.sort(
347 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
348 reverse=True,
349 )
350 return tuple(rows)
351 
352 
353def _review_record(
354 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
355) -> ReviewRecord:
356 """Counts over the completed reviews (resolved-rate is a later loop)."""
357 completed = [r for r in rows if r.status == "completed"]
358 flagged = sum(1 for r in completed if r.findings > 0)
359 hazards = sum(r.findings for r in completed)
360 return ReviewRecord(
361 reviewed=len(completed),
362 flagged=flagged,
363 clean=len(completed) - flagged,
364 hazards=hazards,
365 repos_covered=len(loops),
366 )
367 
368 
369def _sources(
370 github_error: str | None,
371 github_count: int,
372 temporal_error: str | None,
373 temporal_count: int,
374 telemetry: RunTelemetry,
375 clickhouse_error: str | None,
376) -> tuple[SourceHealth, ...]:
377 """Per-source health for the header strip."""
378 clickhouse_ok = clickhouse_error is None
379 if clickhouse_error == "off":
380 clickhouse_detail = "off"
381 elif clickhouse_error is not None:
382 clickhouse_detail = clickhouse_error
383 else:
384 spans = telemetry.total_spans
385 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
386 return (
387 SourceHealth(
388 name="github",
389 ok=github_error is None,
390 detail=github_error or f"{github_count} PRs",
391 ),
392 SourceHealth(
393 name="temporal",
394 ok=temporal_error is None,
395 detail=temporal_error or f"{temporal_count} workflows",
396 ),
397 SourceHealth(
398 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
399 ),
400 )
401 
402 
403def assemble(
404 *,
405 now: datetime,
406 repos: tuple[str, ...],
407 scan_interval_seconds: int,
408 review_interval_seconds: int,
409 github: tuple[tuple[GithubPr, ...], str | None],
410 temporal: tuple[
411 tuple[
412 tuple[ScanExecution, ...],
413 tuple[BumpExecution, ...],
414 tuple[ReviewExecution, ...],
415 tuple[PrReviewExecution, ...],
416 ],
417 str | None,
418 ],
419 telemetry: tuple[RunTelemetry, str | None],
420) -> DashboardModel:
421 """Build the whole view from the readers' ``(data, error)`` outputs."""
422 prs, github_error = github
423 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
424 run_telemetry, clickhouse_error = telemetry
425 
426 rows = _bump_rows(now, prs, bumps)
427 review_loops = _review_loops(repos, reviews)
428 review_rows = _review_rows(pr_reviews, repos)
429 return DashboardModel(
430 generated_at=now,
431 repos_configured=repos,
432 scan_interval_seconds=scan_interval_seconds,
433 sources=_sources(
434 github_error,
435 len(prs),
436 temporal_error,
437 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
438 run_telemetry,
439 clickhouse_error,
440 ),
441 scan_loops=_scan_loops(repos, scans),
442 track_record=_track_record(rows),
443 verification=_verification(rows),
444 judgment=_judgment(rows),
445 gate=_gate(rows),
446 bumps=rows,
447 failures=_failures(bumps),
448 review_interval_seconds=review_interval_seconds,
449 review_loops=review_loops,
450 review_record=_review_record(review_loops, review_rows),
451 reviews=review_rows,
452 telemetry=run_telemetry,
453 )
_review_record — reviewed / flagged / clean / hazards · 453 lines
_review_record — reviewed / flagged / clean / hazards453 lines · Python
⋯ 352 lines hidden (lines 1–352)
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 DashboardModel,
19 Failure,
20 Judgment,
21 ReviewLoop,
22 ReviewRecord,
23 ReviewRow,
24 RunTelemetry,
25 ScanLoop,
26 SourceHealth,
27 TrackRecord,
28 Verification,
30from froot.domain.repo import RepoRef, TargetRepo
31from froot.policy.naming import review_workflow_id, scan_workflow_id
32from froot.result import Ok
33 
34if TYPE_CHECKING:
35 from datetime import datetime
36 
37 from froot.dashboard.github_source import GithubPr
38 from froot.dashboard.temporal_source import (
39 BumpExecution,
40 PrReviewExecution,
41 ReviewExecution,
42 ScanExecution,
43 )
44 
45_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
46# Every way a bump can end without closing cleanly — all belong in the honest
47# Failures panel, not silently dropped.
48_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
49 
50 
51def _median(values: list[float]) -> float | None:
52 """The median of ``values``, or ``None`` if empty (pure)."""
53 if not values:
54 return None
55 ordered = sorted(values)
56 mid = len(ordered) // 2
57 if len(ordered) % 2 == 1:
58 return ordered[mid]
59 return (ordered[mid - 1] + ordered[mid]) / 2
60 
61 
62def _scan_id(repo: str) -> str | None:
63 """The deterministic scan-loop id for an ``owner/name`` slug, if valid."""
64 match RepoRef.parse(repo):
65 case Ok(ref):
66 return scan_workflow_id(TargetRepo(repo=ref))
67 case _:
68 return None
69 
70 
71def _scan_loops(
72 repos: tuple[str, ...], scans: tuple[ScanExecution, ...]
73) -> tuple[ScanLoop, ...]:
74 """One liveness row per configured repo (latest execution wins)."""
75 by_id: dict[str, ScanExecution] = {}
76 for scan in scans:
77 current = by_id.get(scan.workflow_id)
78 if current is None or _newer(scan.start, current.start):
79 by_id[scan.workflow_id] = scan
80 loops: list[ScanLoop] = []
81 for repo in repos:
82 scan_id = _scan_id(repo)
83 execution = by_id.get(scan_id) if scan_id is not None else None
84 if execution is None:
85 loops.append(
86 ScanLoop(repo=repo, status="none", live=False, last_tick=None)
87 )
88 else:
89 loops.append(
90 ScanLoop(
91 repo=repo,
92 status=execution.status,
93 live=execution.status in _LIVE_STATUSES,
94 last_tick=execution.start,
95 )
96 )
97 return tuple(loops)
98 
99 
100def _newer(a: datetime | None, b: datetime | None) -> bool:
101 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
102 if a is None:
103 return False
104 if b is None:
105 return True
106 return a > b
107 
108 
109def _bump_rows(
110 now: datetime,
111 prs: tuple[GithubPr, ...],
112 bumps: tuple[BumpExecution, ...],
113) -> tuple[BumpRow, ...]:
114 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number."""
115 # Keyed on (repo, PR number), not the bare number: Temporal lists every
116 # repo's bumps in the namespace, so two repos that each have a PR #N would
117 # otherwise cross-attribute one's verdict/CI onto the other's row.
118 by_pr: dict[tuple[str, int], BumpExecution] = {
119 (bump.repo, bump.pr_number): bump
120 for bump in bumps
121 if bump.repo is not None and bump.pr_number is not None
122 }
123 rows: list[BumpRow] = []
124 for pr in prs:
125 execution = by_pr.get((pr.repo, pr.number))
126 verdict = (execution.verdict if execution else None) or pr.verdict
127 ci = execution.ci if execution else None
128 ttm = _minutes_between(pr.opened_at, pr.merged_at)
129 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
130 rows.append(
131 BumpRow(
132 repo=pr.repo,
133 package=pr.package or "?",
134 from_version=pr.from_version,
135 to_version=pr.to_version or "?",
136 state=pr.state,
137 verdict=verdict,
138 ci=ci,
139 pr_number=pr.number,
140 pr_url=pr.url,
141 opened_at=pr.opened_at,
142 merged_at=pr.merged_at,
143 ttm_minutes=ttm,
144 age_hours=age,
145 )
146 )
147 rows.sort(key=_opened_sort_key, reverse=True)
148 return tuple(rows)
149 
150 
151def _opened_sort_key(row: BumpRow) -> float:
152 """Sort key putting the most recently opened PR first (unknown last)."""
153 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
154 
155 
156def _minutes_between(
157 start: datetime | None, end: datetime | None
158) -> float | None:
159 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
160 if start is None or end is None:
161 return None
162 return round((end - start).total_seconds() / 60, 1)
163 
164 
165def _hours_between(
166 start: datetime | None, end: datetime | None
167) -> float | None:
168 """Hours from ``start`` to ``end``, or ``None``."""
169 if start is None or end is None:
170 return None
171 return round((end - start).total_seconds() / 3600, 1)
172 
173 
174def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
175 """Counts, merge rate, and median time-to-merge from the bump rows."""
176 merged = [r for r in rows if r.state == "merged"]
177 closed = sum(1 for r in rows if r.state == "closed")
178 open_now = sum(1 for r in rows if r.state == "open")
179 decided = len(merged) + closed
180 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
181 return TrackRecord(
182 opened=len(rows),
183 merged=len(merged),
184 closed_unmerged=closed,
185 open_now=open_now,
186 merge_rate=(len(merged) / decided) if decided else None,
187 median_ttm_minutes=_median(ttms),
188 )
189 
190 
191def _verification(rows: tuple[BumpRow, ...]) -> Verification:
192 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
193 passed = sum(1 for r in rows if r.ci == "passed")
194 failed = sum(1 for r in rows if r.ci == "failed")
195 absent = sum(1 for r in rows if r.ci == "absent")
196 timed_out = sum(1 for r in rows if r.ci == "timed_out")
197 unknown = sum(1 for r in rows if r.ci is None)
198 return Verification(
199 passed=passed,
200 failed=failed,
201 absent=absent,
202 timed_out=timed_out,
203 unknown=unknown,
204 oracle_existed=passed + failed,
205 with_reading=passed + failed + absent + timed_out,
206 )
207 
208 
209def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
210 """The verdict mix plus the two calibration cells worth flagging."""
211 clean = sum(1 for r in rows if r.verdict == "clean")
212 risky = sum(1 for r in rows if r.verdict == "risky")
213 unknown = sum(1 for r in rows if r.verdict == "unknown")
214 none = sum(1 for r in rows if r.verdict is None)
215 clean_but_failed = sum(
216 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
217 )
218 flagged_but_passed = sum(
219 1
220 for r in rows
221 if r.verdict in ("risky", "unknown") and r.ci == "passed"
222 )
223 return Judgment(
224 clean=clean,
225 risky=risky,
226 unknown=unknown,
227 none=none,
228 clean_but_failed=clean_but_failed,
229 flagged_but_passed=flagged_but_passed,
230 )
231 
232 
233def _gate(rows: tuple[BumpRow, ...]) -> tuple[BumpRow, ...]:
234 """Open PRs awaiting a human, the most-aged first (the freshest last)."""
235 open_rows = [r for r in rows if r.state == "open"]
236 open_rows.sort(
237 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
238 reverse=True,
239 )
240 return tuple(open_rows)
241 
242 
243def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
244 """Bump loops that did not close, newest first."""
245 failures = [
246 Failure(
247 workflow_id=bump.workflow_id,
248 kind=bump.status,
249 reason=bump.reason,
250 when=bump.close,
251 )
252 for bump in bumps
253 if bump.status in _FAILURE_STATUSES
254 ]
255 failures.sort(
256 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
257 reverse=True,
258 )
259 return tuple(failures)
260 
261 
262def _review_id(repo: str) -> str | None:
263 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
264 match RepoRef.parse(repo):
265 case Ok(ref):
266 return review_workflow_id(TargetRepo(repo=ref))
267 case _:
268 return None
269 
270 
271def _pr_review_prefix(repo: str) -> str | None:
272 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
273 review_id = _review_id(repo)
274 if review_id is None:
275 return None
276 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
277 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
278 
279 
280def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
281 """The configured repo a per-PR-review id belongs to (longest prefix)."""
282 best: str | None = None
283 best_len = -1
284 for repo in repos:
285 prefix = _pr_review_prefix(repo)
286 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
287 best, best_len = repo, len(prefix)
288 return best
289 
290 
291def _review_loops(
292 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
293) -> tuple[ReviewLoop, ...]:
294 """One liveness row per repo that actually has a review loop.
295 
296 Reviews are scoped to the Temporal repos, so a configured npm repo with no
297 review loop is omitted rather than shown as a dead one.
298 """
299 by_id: dict[str, ReviewExecution] = {}
300 for review in reviews:
301 current = by_id.get(review.workflow_id)
302 if current is None or _newer(review.start, current.start):
303 by_id[review.workflow_id] = review
304 loops: list[ReviewLoop] = []
305 for repo in repos:
306 review_id = _review_id(repo)
307 execution = by_id.get(review_id) if review_id is not None else None
308 if execution is None:
309 continue
310 loops.append(
311 ReviewLoop(
312 repo=repo,
313 status=execution.status,
314 live=execution.status in _LIVE_STATUSES,
315 last_tick=execution.start,
316 )
317 )
318 return tuple(loops)
319 
320 
321def _review_rows(
322 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
323) -> tuple[ReviewRow, ...]:
324 """Project each per-PR review into a row, newest review first."""
325 rows: list[ReviewRow] = []
326 for execution in pr_reviews:
327 repo = _attribute_repo(execution.workflow_id, repos)
328 pr_url = (
329 f"https://github.com/{repo}/pull/{execution.pr_number}"
330 if repo is not None and execution.pr_number is not None
331 else None
332 )
333 rows.append(
334 ReviewRow(
335 repo=repo or "?",
336 pr_number=execution.pr_number,
337 pr_url=pr_url,
338 head_sha=execution.head_sha,
339 findings=execution.findings,
340 rules=execution.rules,
341 comment_url=execution.comment_url,
342 status=execution.status,
343 reviewed_at=execution.close or execution.start,
344 )
345 )
346 rows.sort(
347 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
348 reverse=True,
349 )
350 return tuple(rows)
351 
352 
353def _review_record(
354 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
355) -> ReviewRecord:
356 """Counts over the completed reviews (resolved-rate is a later loop)."""
357 completed = [r for r in rows if r.status == "completed"]
358 flagged = sum(1 for r in completed if r.findings > 0)
359 hazards = sum(r.findings for r in completed)
360 return ReviewRecord(
361 reviewed=len(completed),
362 flagged=flagged,
363 clean=len(completed) - flagged,
364 hazards=hazards,
365 repos_covered=len(loops),
366 )
367 
368 
⋯ 85 lines hidden (lines 369–453)
369def _sources(
370 github_error: str | None,
371 github_count: int,
372 temporal_error: str | None,
373 temporal_count: int,
374 telemetry: RunTelemetry,
375 clickhouse_error: str | None,
376) -> tuple[SourceHealth, ...]:
377 """Per-source health for the header strip."""
378 clickhouse_ok = clickhouse_error is None
379 if clickhouse_error == "off":
380 clickhouse_detail = "off"
381 elif clickhouse_error is not None:
382 clickhouse_detail = clickhouse_error
383 else:
384 spans = telemetry.total_spans
385 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
386 return (
387 SourceHealth(
388 name="github",
389 ok=github_error is None,
390 detail=github_error or f"{github_count} PRs",
391 ),
392 SourceHealth(
393 name="temporal",
394 ok=temporal_error is None,
395 detail=temporal_error or f"{temporal_count} workflows",
396 ),
397 SourceHealth(
398 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
399 ),
400 )
401 
402 
403def assemble(
404 *,
405 now: datetime,
406 repos: tuple[str, ...],
407 scan_interval_seconds: int,
408 review_interval_seconds: int,
409 github: tuple[tuple[GithubPr, ...], str | None],
410 temporal: tuple[
411 tuple[
412 tuple[ScanExecution, ...],
413 tuple[BumpExecution, ...],
414 tuple[ReviewExecution, ...],
415 tuple[PrReviewExecution, ...],
416 ],
417 str | None,
418 ],
419 telemetry: tuple[RunTelemetry, str | None],
420) -> DashboardModel:
421 """Build the whole view from the readers' ``(data, error)`` outputs."""
422 prs, github_error = github
423 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
424 run_telemetry, clickhouse_error = telemetry
425 
426 rows = _bump_rows(now, prs, bumps)
427 review_loops = _review_loops(repos, reviews)
428 review_rows = _review_rows(pr_reviews, repos)
429 return DashboardModel(
430 generated_at=now,
431 repos_configured=repos,
432 scan_interval_seconds=scan_interval_seconds,
433 sources=_sources(
434 github_error,
435 len(prs),
436 temporal_error,
437 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
438 run_telemetry,
439 clickhouse_error,
440 ),
441 scan_loops=_scan_loops(repos, scans),
442 track_record=_track_record(rows),
443 verification=_verification(rows),
444 judgment=_judgment(rows),
445 gate=_gate(rows),
446 bumps=rows,
447 failures=_failures(bumps),
448 review_interval_seconds=review_interval_seconds,
449 review_loops=review_loops,
450 review_record=_review_record(review_loops, review_rows),
451 reviews=review_rows,
452 telemetry=run_telemetry,
453 )

The three sections it renders

Three sections slot in after the bump detail, before telemetry: the review-loop heartbeat (is it alive?), the headline stats (the transitive ring), and the per-review table. A review's findings cell reads clean when none, else the hazard count, the flagged rules, and a one-click link to the advisory comment.

_review_record — the headline + advisory note · 545 lines
_review_record — the headline + advisory note545 lines · Python
⋯ 404 lines hidden (lines 1–404)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 DashboardModel,
20 ReviewLoop,
21 ReviewRow,
22 RunTelemetry,
23 ScanLoop,
24 )
25 
26_CSS = """
27:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
28--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
29@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
30--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
31--accent:#58a6ff;--card:#141414}}
32*{box-sizing:border-box}
33body{margin:0;background:var(--bg);color:var(--fg);
34font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
35main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
36h1{font-size:22px;margin:0;letter-spacing:-.01em}
37.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
38.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
39.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
40section{margin:30px 0 0}
41h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
42color:var(--mut);margin:0 0 12px;font-weight:600;
43border-bottom:1px solid var(--line);padding-bottom:6px}
44.stats{display:flex;flex-wrap:wrap;gap:26px}
45.stat .n{font-size:26px;font-weight:600;line-height:1.1}
46.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
47.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
48margin-right:7px}
49.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
50.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
51table{width:100%;border-collapse:collapse;font-size:13px}
52th,td{text-align:left;padding:6px 12px 6px 0;
53border-bottom:1px solid var(--line);vertical-align:top}
54th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
55letter-spacing:.04em}
56.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
57a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
58.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
59.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
60.bad{color:var(--bad)}
61.note{color:var(--mut);font-size:12px;margin:10px 0 0}
62footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
63color:var(--mut);font-size:12px;line-height:1.7}
64footer b{color:var(--fg);font-weight:600}
65"""
66 
67_CI_CLASS = {
68 "passed": "ok",
69 "failed": "bad",
70 "absent": "mut",
71 "timed_out": "warn",
73_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
74 
75 
76def _aware(when: datetime) -> datetime:
77 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
78 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
79 
80 
81def _ago(when: datetime | None, now: datetime) -> str:
82 """A compact 'time since' label (``6h ago``)."""
83 if when is None:
84 return ""
85 secs = (now - _aware(when)).total_seconds()
86 if secs < 90:
87 return "just now"
88 if secs < 5400:
89 return f"{int(secs // 60)}m ago"
90 if secs < 129600:
91 return f"{int(secs // 3600)}h ago"
92 return f"{int(secs // 86400)}d ago"
93 
94 
95def _until(when: datetime | None, now: datetime) -> str:
96 """A compact 'time until' label (``in 18h`` / ``due now``)."""
97 if when is None:
98 return ""
99 secs = (_aware(when) - now).total_seconds()
100 if secs <= 0:
101 return "due now"
102 if secs < 5400:
103 return f"in {int(secs // 60)}m"
104 if secs < 129600:
105 return f"in {int(secs // 3600)}h"
106 return f"in {int(secs // 86400)}d"
107 
108 
109def _dot(kind: str) -> str:
110 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
111 return f'<span class="dot {kind}"></span>'
112 
113 
114def _tag(value: str | None, classes: dict[str, str]) -> str:
115 """A small coloured label for a verdict/CI value, or an em-dash."""
116 if value is None:
117 return '<span class="mut">—</span>'
118 cls = classes.get(value, "mut")
119 return f'<span class="{cls}">{escape(value)}</span>'
120 
121 
122def _stat(n: object, label: str) -> str:
123 return (
124 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
125 f'<div class="l">{escape(label)}</div></div>'
126 )
127 
128 
129def _header(model: DashboardModel) -> str:
130 now = model.generated_at
131 repos = ", ".join(model.repos_configured) or "none configured"
132 dots = "".join(
133 f"<span>{_dot('ok' if s.ok else 'bad')}"
134 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
135 for s in model.sources
136 )
137 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
138 return (
139 "<header>"
140 "<h1>froot</h1>"
141 '<p class="tag">durable maintenance loops &middot; '
142 "reputation read-model</p>"
143 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
144 f" &middot; generated {escape(stamp)} &middot; "
145 "derived live, stored nowhere</p>"
146 f'<div class="sources">{dots}</div>'
147 "</header>"
148 )
149 
150 
151def _heartbeat(model: DashboardModel) -> str:
152 now = model.generated_at
153 interval = model.scan_interval_seconds
154 
155 def line(loop: ScanLoop) -> str:
156 if loop.live:
157 dot, tail = "ok", ""
158 if loop.last_tick is not None:
159 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
160 last = _ago(loop.last_tick, now)
161 tail = (
162 f' <span class="mut">&middot; last {last}'
163 f" &middot; next {_until(nxt, now)}</span>"
164 )
165 else:
166 dot = "bad" if loop.status in ("terminated", "none") else "warn"
167 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
168 return (
169 f'<div class="row">{_dot(dot)}'
170 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
171 )
172 
173 if not model.scan_loops:
174 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
175 else:
176 body = "".join(line(loop) for loop in model.scan_loops)
177 return f"<section><h2>Is the loop alive?</h2>{body}</section>"
178 
179 
180def _track_record(model: DashboardModel) -> str:
181 t = model.track_record
182 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
183 ttm = (
184 ""
185 if t.median_ttm_minutes is None
186 else f"{t.median_ttm_minutes:.0f} min"
187 )
188 stats = "".join(
189 (
190 _stat(t.opened, "proposed"),
191 _stat(t.merged, "merged"),
192 _stat(t.open_now, "awaiting"),
193 _stat(t.closed_unmerged, "closed"),
194 _stat(rate, "merge rate"),
195 _stat(ttm, "median time-to-merge"),
196 )
197 )
198 note = (
199 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
200 "npm patch bumps by construction. It counts a human merge, not a "
201 "confirmed good outcome: revert tracking is a later loop, so merge is "
202 "not yet proof of success.</p>"
203 )
204 return (
205 "<section><h2>Track record &middot; the reputation</h2>"
206 f'<div class="stats">{stats}</div>{note}</section>'
207 )
208 
209 
210def _verification(model: DashboardModel) -> str:
211 v = model.verification
212 stats = "".join(
213 (
214 _stat(v.passed, "CI passed"),
215 _stat(v.failed, "CI failed"),
216 _stat(v.absent, "no checks"),
217 _stat(v.timed_out, "timed out"),
218 _stat(v.unknown, "unknown"),
219 )
220 )
221 if v.with_reading == 0:
222 note = '<p class="note">No CI readings yet.</p>'
223 else:
224 note = (
225 f'<p class="note">A real oracle reported on '
226 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
227 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
228 "absent &mdash; not a pass; never conflated.</span>"
229 "</p>"
230 )
231 return (
232 "<section><h2>Verification &middot; CI is the oracle</h2>"
233 f'<div class="stats">{stats}</div>{note}</section>'
234 )
235 
236 
237def _judgment(model: DashboardModel) -> str:
238 j = model.judgment
239 stats = "".join(
240 (
241 _stat(j.clean, "clean"),
242 _stat(j.risky, "risky"),
243 _stat(j.unknown, "unknown"),
244 _stat(j.none, "no verdict"),
245 )
246 )
247 if j.clean_but_failed or j.flagged_but_passed:
248 note = (
249 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
250 "&lsquo;clean&rsquo; bumps whose CI failed, "
251 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
252 )
253 else:
254 note = (
255 '<p class="note">The model&rsquo;s only job is the changelog '
256 "verdict; the spine proposes the bump either way.</p>"
257 )
258 return (
259 "<section><h2>Model judgment &middot; the one model call</h2>"
260 f'<div class="stats">{stats}</div>{note}</section>'
261 )
262 
263 
264def _gate(model: DashboardModel) -> str:
265 now = model.generated_at
266 if not model.gate:
267 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
268 else:
269 rows = "".join(
270 "<tr>"
271 f'<td class="mono">{escape(row.package)}</td>'
272 f"<td>{escape(_ago(row.opened_at, now))}</td>"
273 f"<td>{_pr_link(row)}</td>"
274 "</tr>"
275 for row in model.gate
276 )
277 body = (
278 "<table><thead><tr><th>package</th><th>waiting</th><th>pr</th>"
279 f"</tr></thead><tbody>{rows}</tbody></table>"
280 )
281 return (
282 "<section><h2>Approval gate &middot; what a human owns</h2>"
283 f"{body}</section>"
284 )
285 
286 
287def _bumps(model: DashboardModel) -> str:
288 now = model.generated_at
289 if not model.bumps:
290 body = '<p class="note">No bumps proposed yet.</p>'
291 else:
292 rows = "".join(
293 "<tr>"
294 f'<td class="mono">{escape(row.package)}</td>'
295 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
296 f"{escape(row.to_version)}</td>"
297 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
298 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
299 f"<td>{_state_tag(row.state)}</td>"
300 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
301 f"<td>{_pr_link(row)}</td>"
302 "</tr>"
303 for row in model.bumps
304 )
305 body = (
306 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
307 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
308 f"<tbody>{rows}</tbody></table>"
309 )
310 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
311 
312 
313def _failures(model: DashboardModel) -> str:
314 if not model.failures:
315 return ""
316 now = model.generated_at
317 rows = "".join(
318 "<tr>"
319 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
320 f"<td>{_state_tag(f.kind)}</td>"
321 f'<td class="mut">{escape(f.reason or "")}</td>'
322 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
323 "</tr>"
324 for f in model.failures
325 )
326 return (
327 "<section><h2>Failures &middot; where the loop did not close</h2>"
328 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
329 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
330 )
331 
332 
333def _telemetry(model: DashboardModel) -> str:
334 t: RunTelemetry = model.telemetry
335 if not t.available:
336 return (
337 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
338 '<p class="note">Unavailable (not configured or unreachable). '
339 "GitHub + Temporal carry the dashboard regardless.</p></section>"
340 )
341 now = model.generated_at
342 if t.activities:
343 rows = "".join(
344 "<tr>"
345 f'<td class="mono">{escape(a.name)}</td>'
346 f"<td>{a.count}</td>"
347 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
348 f'<td class="mut">{a.max_ms:.0f} ms</td>'
349 "</tr>"
350 for a in t.activities
351 )
352 table = (
353 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
354 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
355 )
356 else:
357 table = '<p class="note">No froot spans in the window.</p>'
358 summary = (
359 f'<p class="note">{t.total_spans} spans &middot; '
360 f"{t.error_spans} errored &middot; last activity "
361 f"{escape(_ago(t.last_activity, now))} &middot; "
362 f"{t.window_days}-day window.</p>"
363 )
364 return (
365 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
366 f"{summary}{table}</section>"
367 )
368 
369 
370def _review_heartbeat(model: DashboardModel) -> str:
371 now = model.generated_at
372 interval = model.review_interval_seconds
373 
374 def line(loop: ReviewLoop) -> str:
375 if loop.live:
376 dot, tail = "ok", ""
377 if loop.last_tick is not None:
378 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
379 last = _ago(loop.last_tick, now)
380 tail = (
381 f' <span class="mut">&middot; last {last}'
382 f" &middot; next {_until(nxt, now)}</span>"
383 )
384 else:
385 dot = "bad" if loop.status in ("terminated", "none") else "warn"
386 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
387 return (
388 f'<div class="row">{_dot(dot)}'
389 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
390 )
391 
392 if not model.review_loops:
393 body = (
394 '<p class="note">No determinism-review loops running '
395 "(the transitive ring watches the @workflow.defn repos).</p>"
396 )
397 else:
398 body = "".join(line(loop) for loop in model.review_loops)
399 return (
400 "<section><h2>Determinism review &middot; is it alive?</h2>"
401 f"{body}</section>"
402 )
403 
404 
405def _review_record(model: DashboardModel) -> str:
406 r = model.review_record
407 stats = "".join(
408 (
409 _stat(r.reviewed, "reviewed"),
410 _stat(r.flagged, "flagged"),
411 _stat(r.clean, "clean"),
412 _stat(r.hazards, "hazards"),
413 _stat(r.repos_covered, "repos covered"),
414 )
415 )
416 note = (
417 '<p class="note">The transitive ring: it chases first-party helper '
418 "calls out of each workflow to catch a hazard the lexical CI kernel "
419 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
420 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
421 "a later commit?) is a later loop; it needs accumulated history.</p>"
422 )
423 return (
424 "<section><h2>Determinism review &middot; the transitive ring</h2>"
425 f'<div class="stats">{stats}</div>{note}</section>'
426 )
427 
428 
⋯ 117 lines hidden (lines 429–545)
429def _reviews(model: DashboardModel) -> str:
430 now = model.generated_at
431 if not model.reviews:
432 body = '<p class="note">No PRs reviewed yet.</p>'
433 else:
434 rows = "".join(
435 "<tr>"
436 f'<td class="mono">{escape(row.repo)}</td>'
437 f"<td>{_review_pr_link(row)}</td>"
438 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
439 "</td>"
440 f"<td>{_findings_cell(row)}</td>"
441 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
442 "</tr>"
443 for row in model.reviews
444 )
445 body = (
446 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
447 "<th>findings</th><th>reviewed</th></tr></thead>"
448 f"<tbody>{rows}</tbody></table>"
449 )
450 return (
451 "<section><h2>Determinism reviews &middot; the detail</h2>"
452 f"{body}</section>"
453 )
454 
455 
456def _footer() -> str:
457 return (
458 "<footer>"
459 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
460 "<b>write authority</b> only: it opens PRs, a human approves every "
461 "merge (commit authority = none). Trust, when any is granted, is "
462 "earned, narrow to npm patch bumps, conditional on its environment "
463 '(judge <span class="mono">gemma4:e4b</span>, lockfile-only regen), '
464 "revocable, and time-expiring. Today it records the track record; it "
465 "does not yet act on it.<br>"
466 "Everything above is derived on this request from GitHub (outcomes) + "
467 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
468 "reload to recompute."
469 "</footer>"
470 )
471 
472 
473def _pr_link(row: BumpRow) -> str:
474 if row.pr_url is None or row.pr_number is None:
475 return '<span class="mut">—</span>'
476 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
477 
478 
479def _review_pr_link(row: ReviewRow) -> str:
480 if row.pr_url is None or row.pr_number is None:
481 return '<span class="mut">—</span>'
482 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
483 
484 
485def _findings_cell(row: ReviewRow) -> str:
486 """A review's findings: 'clean', or the hazard count + rules + comment."""
487 if row.findings == 0:
488 return '<span class="ok">clean</span>'
489 rules = (
490 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
491 if row.rules
492 else ""
493 )
494 comment = (
495 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
496 if row.comment_url
497 else ""
498 )
499 noun = "hazard" if row.findings == 1 else "hazards"
500 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
501 
502 
503def _state_tag(state: str) -> str:
504 cls = {
505 "merged": "ok",
506 "open": "warn",
507 "closed": "mut",
508 "terminated": "bad",
509 "failed": "bad",
510 "timed_out": "bad",
511 "canceled": "warn",
512 }.get(state, "mut")
513 return f'<span class="{cls}">{escape(state)}</span>'
514 
515 
516def _short_id(workflow_id: str) -> str:
517 """Drop the ``froot-bump-`` prefix for a readable failures row."""
518 return workflow_id.removeprefix("froot-bump-")
519 
520 
521def page(model: DashboardModel) -> str:
522 """Render the whole dashboard as one self-contained HTML document."""
523 parts = (
524 _header(model),
525 _heartbeat(model),
526 _track_record(model),
527 _verification(model),
528 _judgment(model),
529 _gate(model),
530 _bumps(model),
531 _failures(model),
532 _review_heartbeat(model),
533 _review_record(model),
534 _reviews(model),
535 _telemetry(model),
536 _footer(),
537 )
538 return (
539 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
540 '<meta name="viewport" content="width=device-width,initial-scale=1">'
541 "<title>froot &middot; read-model</title>"
542 f"<style>{_CSS}</style></head><body><main>"
543 + "".join(parts)
544 + "</main></body></html>"
545 )
_findings_cell — clean, or count + rules + comment · 545 lines
_findings_cell — clean, or count + rules + comment545 lines · Python
⋯ 484 lines hidden (lines 1–484)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 DashboardModel,
20 ReviewLoop,
21 ReviewRow,
22 RunTelemetry,
23 ScanLoop,
24 )
25 
26_CSS = """
27:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
28--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
29@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
30--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
31--accent:#58a6ff;--card:#141414}}
32*{box-sizing:border-box}
33body{margin:0;background:var(--bg);color:var(--fg);
34font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
35main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
36h1{font-size:22px;margin:0;letter-spacing:-.01em}
37.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
38.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
39.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
40section{margin:30px 0 0}
41h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
42color:var(--mut);margin:0 0 12px;font-weight:600;
43border-bottom:1px solid var(--line);padding-bottom:6px}
44.stats{display:flex;flex-wrap:wrap;gap:26px}
45.stat .n{font-size:26px;font-weight:600;line-height:1.1}
46.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
47.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
48margin-right:7px}
49.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
50.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
51table{width:100%;border-collapse:collapse;font-size:13px}
52th,td{text-align:left;padding:6px 12px 6px 0;
53border-bottom:1px solid var(--line);vertical-align:top}
54th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
55letter-spacing:.04em}
56.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
57a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
58.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
59.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
60.bad{color:var(--bad)}
61.note{color:var(--mut);font-size:12px;margin:10px 0 0}
62footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
63color:var(--mut);font-size:12px;line-height:1.7}
64footer b{color:var(--fg);font-weight:600}
65"""
66 
67_CI_CLASS = {
68 "passed": "ok",
69 "failed": "bad",
70 "absent": "mut",
71 "timed_out": "warn",
73_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
74 
75 
76def _aware(when: datetime) -> datetime:
77 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
78 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
79 
80 
81def _ago(when: datetime | None, now: datetime) -> str:
82 """A compact 'time since' label (``6h ago``)."""
83 if when is None:
84 return ""
85 secs = (now - _aware(when)).total_seconds()
86 if secs < 90:
87 return "just now"
88 if secs < 5400:
89 return f"{int(secs // 60)}m ago"
90 if secs < 129600:
91 return f"{int(secs // 3600)}h ago"
92 return f"{int(secs // 86400)}d ago"
93 
94 
95def _until(when: datetime | None, now: datetime) -> str:
96 """A compact 'time until' label (``in 18h`` / ``due now``)."""
97 if when is None:
98 return ""
99 secs = (_aware(when) - now).total_seconds()
100 if secs <= 0:
101 return "due now"
102 if secs < 5400:
103 return f"in {int(secs // 60)}m"
104 if secs < 129600:
105 return f"in {int(secs // 3600)}h"
106 return f"in {int(secs // 86400)}d"
107 
108 
109def _dot(kind: str) -> str:
110 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
111 return f'<span class="dot {kind}"></span>'
112 
113 
114def _tag(value: str | None, classes: dict[str, str]) -> str:
115 """A small coloured label for a verdict/CI value, or an em-dash."""
116 if value is None:
117 return '<span class="mut">—</span>'
118 cls = classes.get(value, "mut")
119 return f'<span class="{cls}">{escape(value)}</span>'
120 
121 
122def _stat(n: object, label: str) -> str:
123 return (
124 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
125 f'<div class="l">{escape(label)}</div></div>'
126 )
127 
128 
129def _header(model: DashboardModel) -> str:
130 now = model.generated_at
131 repos = ", ".join(model.repos_configured) or "none configured"
132 dots = "".join(
133 f"<span>{_dot('ok' if s.ok else 'bad')}"
134 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
135 for s in model.sources
136 )
137 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
138 return (
139 "<header>"
140 "<h1>froot</h1>"
141 '<p class="tag">durable maintenance loops &middot; '
142 "reputation read-model</p>"
143 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
144 f" &middot; generated {escape(stamp)} &middot; "
145 "derived live, stored nowhere</p>"
146 f'<div class="sources">{dots}</div>'
147 "</header>"
148 )
149 
150 
151def _heartbeat(model: DashboardModel) -> str:
152 now = model.generated_at
153 interval = model.scan_interval_seconds
154 
155 def line(loop: ScanLoop) -> str:
156 if loop.live:
157 dot, tail = "ok", ""
158 if loop.last_tick is not None:
159 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
160 last = _ago(loop.last_tick, now)
161 tail = (
162 f' <span class="mut">&middot; last {last}'
163 f" &middot; next {_until(nxt, now)}</span>"
164 )
165 else:
166 dot = "bad" if loop.status in ("terminated", "none") else "warn"
167 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
168 return (
169 f'<div class="row">{_dot(dot)}'
170 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
171 )
172 
173 if not model.scan_loops:
174 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
175 else:
176 body = "".join(line(loop) for loop in model.scan_loops)
177 return f"<section><h2>Is the loop alive?</h2>{body}</section>"
178 
179 
180def _track_record(model: DashboardModel) -> str:
181 t = model.track_record
182 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
183 ttm = (
184 ""
185 if t.median_ttm_minutes is None
186 else f"{t.median_ttm_minutes:.0f} min"
187 )
188 stats = "".join(
189 (
190 _stat(t.opened, "proposed"),
191 _stat(t.merged, "merged"),
192 _stat(t.open_now, "awaiting"),
193 _stat(t.closed_unmerged, "closed"),
194 _stat(rate, "merge rate"),
195 _stat(ttm, "median time-to-merge"),
196 )
197 )
198 note = (
199 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
200 "npm patch bumps by construction. It counts a human merge, not a "
201 "confirmed good outcome: revert tracking is a later loop, so merge is "
202 "not yet proof of success.</p>"
203 )
204 return (
205 "<section><h2>Track record &middot; the reputation</h2>"
206 f'<div class="stats">{stats}</div>{note}</section>'
207 )
208 
209 
210def _verification(model: DashboardModel) -> str:
211 v = model.verification
212 stats = "".join(
213 (
214 _stat(v.passed, "CI passed"),
215 _stat(v.failed, "CI failed"),
216 _stat(v.absent, "no checks"),
217 _stat(v.timed_out, "timed out"),
218 _stat(v.unknown, "unknown"),
219 )
220 )
221 if v.with_reading == 0:
222 note = '<p class="note">No CI readings yet.</p>'
223 else:
224 note = (
225 f'<p class="note">A real oracle reported on '
226 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
227 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
228 "absent &mdash; not a pass; never conflated.</span>"
229 "</p>"
230 )
231 return (
232 "<section><h2>Verification &middot; CI is the oracle</h2>"
233 f'<div class="stats">{stats}</div>{note}</section>'
234 )
235 
236 
237def _judgment(model: DashboardModel) -> str:
238 j = model.judgment
239 stats = "".join(
240 (
241 _stat(j.clean, "clean"),
242 _stat(j.risky, "risky"),
243 _stat(j.unknown, "unknown"),
244 _stat(j.none, "no verdict"),
245 )
246 )
247 if j.clean_but_failed or j.flagged_but_passed:
248 note = (
249 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
250 "&lsquo;clean&rsquo; bumps whose CI failed, "
251 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
252 )
253 else:
254 note = (
255 '<p class="note">The model&rsquo;s only job is the changelog '
256 "verdict; the spine proposes the bump either way.</p>"
257 )
258 return (
259 "<section><h2>Model judgment &middot; the one model call</h2>"
260 f'<div class="stats">{stats}</div>{note}</section>'
261 )
262 
263 
264def _gate(model: DashboardModel) -> str:
265 now = model.generated_at
266 if not model.gate:
267 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
268 else:
269 rows = "".join(
270 "<tr>"
271 f'<td class="mono">{escape(row.package)}</td>'
272 f"<td>{escape(_ago(row.opened_at, now))}</td>"
273 f"<td>{_pr_link(row)}</td>"
274 "</tr>"
275 for row in model.gate
276 )
277 body = (
278 "<table><thead><tr><th>package</th><th>waiting</th><th>pr</th>"
279 f"</tr></thead><tbody>{rows}</tbody></table>"
280 )
281 return (
282 "<section><h2>Approval gate &middot; what a human owns</h2>"
283 f"{body}</section>"
284 )
285 
286 
287def _bumps(model: DashboardModel) -> str:
288 now = model.generated_at
289 if not model.bumps:
290 body = '<p class="note">No bumps proposed yet.</p>'
291 else:
292 rows = "".join(
293 "<tr>"
294 f'<td class="mono">{escape(row.package)}</td>'
295 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
296 f"{escape(row.to_version)}</td>"
297 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
298 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
299 f"<td>{_state_tag(row.state)}</td>"
300 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
301 f"<td>{_pr_link(row)}</td>"
302 "</tr>"
303 for row in model.bumps
304 )
305 body = (
306 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
307 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
308 f"<tbody>{rows}</tbody></table>"
309 )
310 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
311 
312 
313def _failures(model: DashboardModel) -> str:
314 if not model.failures:
315 return ""
316 now = model.generated_at
317 rows = "".join(
318 "<tr>"
319 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
320 f"<td>{_state_tag(f.kind)}</td>"
321 f'<td class="mut">{escape(f.reason or "")}</td>'
322 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
323 "</tr>"
324 for f in model.failures
325 )
326 return (
327 "<section><h2>Failures &middot; where the loop did not close</h2>"
328 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
329 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
330 )
331 
332 
333def _telemetry(model: DashboardModel) -> str:
334 t: RunTelemetry = model.telemetry
335 if not t.available:
336 return (
337 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
338 '<p class="note">Unavailable (not configured or unreachable). '
339 "GitHub + Temporal carry the dashboard regardless.</p></section>"
340 )
341 now = model.generated_at
342 if t.activities:
343 rows = "".join(
344 "<tr>"
345 f'<td class="mono">{escape(a.name)}</td>'
346 f"<td>{a.count}</td>"
347 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
348 f'<td class="mut">{a.max_ms:.0f} ms</td>'
349 "</tr>"
350 for a in t.activities
351 )
352 table = (
353 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
354 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
355 )
356 else:
357 table = '<p class="note">No froot spans in the window.</p>'
358 summary = (
359 f'<p class="note">{t.total_spans} spans &middot; '
360 f"{t.error_spans} errored &middot; last activity "
361 f"{escape(_ago(t.last_activity, now))} &middot; "
362 f"{t.window_days}-day window.</p>"
363 )
364 return (
365 "<section><h2>Run telemetry &middot; ClickHouse</h2>"
366 f"{summary}{table}</section>"
367 )
368 
369 
370def _review_heartbeat(model: DashboardModel) -> str:
371 now = model.generated_at
372 interval = model.review_interval_seconds
373 
374 def line(loop: ReviewLoop) -> str:
375 if loop.live:
376 dot, tail = "ok", ""
377 if loop.last_tick is not None:
378 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
379 last = _ago(loop.last_tick, now)
380 tail = (
381 f' <span class="mut">&middot; last {last}'
382 f" &middot; next {_until(nxt, now)}</span>"
383 )
384 else:
385 dot = "bad" if loop.status in ("terminated", "none") else "warn"
386 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
387 return (
388 f'<div class="row">{_dot(dot)}'
389 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
390 )
391 
392 if not model.review_loops:
393 body = (
394 '<p class="note">No determinism-review loops running '
395 "(the transitive ring watches the @workflow.defn repos).</p>"
396 )
397 else:
398 body = "".join(line(loop) for loop in model.review_loops)
399 return (
400 "<section><h2>Determinism review &middot; is it alive?</h2>"
401 f"{body}</section>"
402 )
403 
404 
405def _review_record(model: DashboardModel) -> str:
406 r = model.review_record
407 stats = "".join(
408 (
409 _stat(r.reviewed, "reviewed"),
410 _stat(r.flagged, "flagged"),
411 _stat(r.clean, "clean"),
412 _stat(r.hazards, "hazards"),
413 _stat(r.repos_covered, "repos covered"),
414 )
415 )
416 note = (
417 '<p class="note">The transitive ring: it chases first-party helper '
418 "calls out of each workflow to catch a hazard the lexical CI kernel "
419 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
420 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
421 "a later commit?) is a later loop; it needs accumulated history.</p>"
422 )
423 return (
424 "<section><h2>Determinism review &middot; the transitive ring</h2>"
425 f'<div class="stats">{stats}</div>{note}</section>'
426 )
427 
428 
429def _reviews(model: DashboardModel) -> str:
430 now = model.generated_at
431 if not model.reviews:
432 body = '<p class="note">No PRs reviewed yet.</p>'
433 else:
434 rows = "".join(
435 "<tr>"
436 f'<td class="mono">{escape(row.repo)}</td>'
437 f"<td>{_review_pr_link(row)}</td>"
438 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
439 "</td>"
440 f"<td>{_findings_cell(row)}</td>"
441 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
442 "</tr>"
443 for row in model.reviews
444 )
445 body = (
446 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
447 "<th>findings</th><th>reviewed</th></tr></thead>"
448 f"<tbody>{rows}</tbody></table>"
449 )
450 return (
451 "<section><h2>Determinism reviews &middot; the detail</h2>"
452 f"{body}</section>"
453 )
454 
455 
456def _footer() -> str:
457 return (
458 "<footer>"
459 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
460 "<b>write authority</b> only: it opens PRs, a human approves every "
461 "merge (commit authority = none). Trust, when any is granted, is "
462 "earned, narrow to npm patch bumps, conditional on its environment "
463 '(judge <span class="mono">gemma4:e4b</span>, lockfile-only regen), '
464 "revocable, and time-expiring. Today it records the track record; it "
465 "does not yet act on it.<br>"
466 "Everything above is derived on this request from GitHub (outcomes) + "
467 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
468 "reload to recompute."
469 "</footer>"
470 )
471 
472 
473def _pr_link(row: BumpRow) -> str:
474 if row.pr_url is None or row.pr_number is None:
475 return '<span class="mut">—</span>'
476 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
477 
478 
479def _review_pr_link(row: ReviewRow) -> str:
480 if row.pr_url is None or row.pr_number is None:
481 return '<span class="mut">—</span>'
482 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
483 
484 
485def _findings_cell(row: ReviewRow) -> str:
486 """A review's findings: 'clean', or the hazard count + rules + comment."""
487 if row.findings == 0:
488 return '<span class="ok">clean</span>'
489 rules = (
490 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
491 if row.rules
492 else ""
493 )
494 comment = (
495 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
496 if row.comment_url
497 else ""
498 )
499 noun = "hazard" if row.findings == 1 else "hazards"
500 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
501 
502 
503def _state_tag(state: str) -> str:
504 cls = {
505 "merged": "ok",
506 "open": "warn",
⋯ 39 lines hidden (lines 507–545)
507 "closed": "mut",
508 "terminated": "bad",
509 "failed": "bad",
510 "timed_out": "bad",
511 "canceled": "warn",
512 }.get(state, "mut")
513 return f'<span class="{cls}">{escape(state)}</span>'
514 
515 
516def _short_id(workflow_id: str) -> str:
517 """Drop the ``froot-bump-`` prefix for a readable failures row."""
518 return workflow_id.removeprefix("froot-bump-")
519 
520 
521def page(model: DashboardModel) -> str:
522 """Render the whole dashboard as one self-contained HTML document."""
523 parts = (
524 _header(model),
525 _heartbeat(model),
526 _track_record(model),
527 _verification(model),
528 _judgment(model),
529 _gate(model),
530 _bumps(model),
531 _failures(model),
532 _review_heartbeat(model),
533 _review_record(model),
534 _reviews(model),
535 _telemetry(model),
536 _footer(),
537 )
538 return (
539 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
540 '<meta name="viewport" content="width=device-width,initial-scale=1">'
541 "<title>froot &middot; read-model</title>"
542 f"<style>{_CSS}</style></head><body><main>"
543 + "".join(parts)
544 + "</main></body></html>"
545 )

Verified: ruff (format + lint), mypy strict (93 files), and 186 tests — 11 new, covering loop liveness, the record counts, the repo-prefix attribution, and the rendered sections (flagged vs. clean).