What changed, and why

froot opens dependency- and security-patch PRs and a human approves every merge. It has been record-only: the dashboard reports the track record but draws no conclusion from it. Phase 3 makes that record legible as trust.

For each (repo, loop) class it now derives whether the class has earned a hypothetical auto-merge gate move, and for each open PR whether it would auto-merge under that grant. This is the shadow gate — a dry run a steward watches before any authority is granted. Nothing acts. Phase 4 will flip the same pure functions to action; this PR only computes and renders the verdict.

The change is grounded in the Many Hands Engineering (MHE) trust economy. Read it in four beats: the pure verdict (policy/autonomy.py), how the read-model derives each class's standing, what the page shows, and a separate run-ledger instrumentation beat that makes the scan stage legible.

The pure verdict — policy/autonomy.py

The whole decision lives in one pure, dependency-free module so Phase 4 can reuse it verbatim. class_earned answers "has this class earned its gate?" from a track record; pr_autonomy answers "would this PR ride the grant?".

The module docstring names MHE's five properties the grant carries — earned, narrow, conditional, revocable, expiring — and is blunt about the caveat that keeps this advisory: the approval rate "is the headline, not the whole truth".

The five properties, then class_earned: enough decided PRs at a high enough rate, measured over a recent window.

src/froot/policy/autonomy.py · 125 lines
src/froot/policy/autonomy.py125 lines · Python
⋯ 10 lines hidden (lines 1–10)
1"""The earned-autonomy policy — read-only today, the gate's logic tomorrow.
2 
3froot is record-only: every PR is human-approved. This is the pure logic that
4says, per the Many Hands Engineering trust economy (§3.6-3.7), whether a *class*
5of work (a loop, on a repo) has earned its gate move, and whether a given PR
6*would* auto-merge under that grant. Today the dashboard renders the verdict
7and nothing acts on it — the "shadow gate", the dry run that lets a steward
8watch the decision for weeks before granting authority. Phase 4 flips it to
9action by reusing these same functions; nothing here mutates anything.
10 
11The grant has MHE's five properties. **Earned**: a class earns its gate by a
12track record — a high enough *approval rate* over enough *decided* PRs.
13**Narrow**: the class is one loop on one repo; dependency-patch and
14security-patch are separate trust classes (§3.9), never sharing a record.
15**Conditional**: a PR rides the grant only if its own changelog read clean and
16its CI went green. **Revocable**: the grant is gated behind an explicit
17per-repo allowlist a steward controls, and lapses on its own when the rate
18falls. **Expiring**: the rate is measured over a recent window, not all of
19history (§2.11) — a class that has not acted lately starts from a lower
20baseline.
⋯ 44 lines hidden (lines 21–64)
21 
22A caveat MHE is blunt about: the approval rate is the headline, *not the whole
23truth* — "track record alone can lie" (§3.7). A real grant triangulates it with
24post-merge rollback rate and sampled review, and resets when the *environment*
25it was earned in changes (a judge-model swap, a refactor) — MHE's fuller sense
26of "conditional". Those are deliberately not here yet, which is exactly why
27this stays advisory.
28"""
29 
30from __future__ import annotations
31 
32from froot.domain.base import Frozen
33 
34 
35class AutonomyPolicy(Frozen):
36 """The thresholds a class must clear to earn its gate move (advisory).
37 
38 Attributes:
39 min_rate: The approval (merge) rate a class needs over the window.
40 min_decided: How many PRs must have been decided in the window before
41 the rate means anything — a 100% rate on one PR is not a record.
42 window_days: The look-back window; trust is recent, not lifetime.
43 allowlisted_repos: The repos a steward has opted into auto-merge for.
44 Empty by default — the revocable switch, off until trust is granted.
45 """
46 
47 min_rate: float = 0.95
48 min_decided: int = 5
49 window_days: int = 90
50 allowlisted_repos: frozenset[str] = frozenset()
51 
52 
53class AutonomyVerdict(Frozen):
54 """Whether a PR would auto-merge under the grant, and the reason either way.
55 
56 Attributes:
57 would_merge: True iff every condition is met (advisory — nothing acts).
58 reason: The deciding factor — the grant met, or the first blocker.
59 """
60 
61 would_merge: bool
62 reason: str
63 
64 
65def class_earned(
66 decided: int, merged: int, policy: AutonomyPolicy
67) -> tuple[bool, str | None]:
68 """Whether a class has earned its gate move, and why not if it hasn't.
69 
70 Args:
71 decided: PRs of this class decided (merged or closed) in the window.
72 merged: How many of those were merged.
73 policy: The thresholds.
74 
75 Returns:
76 ``(earned, blocker)`` — ``blocker`` is ``None`` when earned, else the
77 short reason the gate has not moved.
78 """
79 if decided < policy.min_decided or decided == 0:
80 return False, f"only {decided}/{policy.min_decided} decided recently"
81 rate = merged / decided
82 if rate < policy.min_rate:
83 return False, (f"approval rate {rate:.0%} < {policy.min_rate:.0%}")
84 return True, None
⋯ 41 lines hidden (lines 85–125)
85 
86 
87def pr_autonomy(
88 *,
89 repo: str,
90 verdict: str | None,
91 ci: str | None,
92 earned: bool,
93 blocker: str | None,
94 policy: AutonomyPolicy,
95) -> AutonomyVerdict:
96 """Whether one open PR would auto-merge under its class's grant.
97 
98 Reports the *substantive* blockers first — the earned grant, then this PR's
99 own clean-and-green conditions — and the steward's own revocable switch
100 (the allowlist) *last*. That ordering is what makes the shadow gate
101 watchable in its default, allowlist-off state: a held PR shows the real
102 thing to fix (``CI pending`` / ``class not earned``), and the bare
103 ``auto-merge not enabled`` reason appears only once a PR is otherwise fully
104 ready — i.e. exactly when flipping the switch would change the outcome.
105 ``would_merge`` still requires *every* condition, the allowlist included.
106 """
107 if not earned:
108 return AutonomyVerdict(
109 would_merge=False, reason=f"class not earned ({blocker})"
110 )
111 if verdict != "clean":
112 return AutonomyVerdict(
113 would_merge=False, reason=f"verdict is {verdict or 'unknown'}"
114 )
115 if ci != "passed":
116 return AutonomyVerdict(
117 would_merge=False, reason=f"CI {ci or 'pending'}"
118 )
119 if repo not in policy.allowlisted_repos:
120 return AutonomyVerdict(
121 would_merge=False, reason="auto-merge not enabled for this repo"
122 )
123 return AutonomyVerdict(
124 would_merge=True, reason="clean + green on an earned class"
125 )

pr_autonomy: the substantive blockers first, the steward's own allowlist switch last.

src/froot/policy/autonomy.py · 125 lines
src/froot/policy/autonomy.py125 lines · Python
⋯ 86 lines hidden (lines 1–86)
1"""The earned-autonomy policy — read-only today, the gate's logic tomorrow.
2 
3froot is record-only: every PR is human-approved. This is the pure logic that
4says, per the Many Hands Engineering trust economy (§3.6-3.7), whether a *class*
5of work (a loop, on a repo) has earned its gate move, and whether a given PR
6*would* auto-merge under that grant. Today the dashboard renders the verdict
7and nothing acts on it — the "shadow gate", the dry run that lets a steward
8watch the decision for weeks before granting authority. Phase 4 flips it to
9action by reusing these same functions; nothing here mutates anything.
10 
11The grant has MHE's five properties. **Earned**: a class earns its gate by a
12track record — a high enough *approval rate* over enough *decided* PRs.
13**Narrow**: the class is one loop on one repo; dependency-patch and
14security-patch are separate trust classes (§3.9), never sharing a record.
15**Conditional**: a PR rides the grant only if its own changelog read clean and
16its CI went green. **Revocable**: the grant is gated behind an explicit
17per-repo allowlist a steward controls, and lapses on its own when the rate
18falls. **Expiring**: the rate is measured over a recent window, not all of
19history (§2.11) — a class that has not acted lately starts from a lower
20baseline.
21 
22A caveat MHE is blunt about: the approval rate is the headline, *not the whole
23truth* — "track record alone can lie" (§3.7). A real grant triangulates it with
24post-merge rollback rate and sampled review, and resets when the *environment*
25it was earned in changes (a judge-model swap, a refactor) — MHE's fuller sense
26of "conditional". Those are deliberately not here yet, which is exactly why
27this stays advisory.
28"""
29 
30from __future__ import annotations
31 
32from froot.domain.base import Frozen
33 
34 
35class AutonomyPolicy(Frozen):
36 """The thresholds a class must clear to earn its gate move (advisory).
37 
38 Attributes:
39 min_rate: The approval (merge) rate a class needs over the window.
40 min_decided: How many PRs must have been decided in the window before
41 the rate means anything — a 100% rate on one PR is not a record.
42 window_days: The look-back window; trust is recent, not lifetime.
43 allowlisted_repos: The repos a steward has opted into auto-merge for.
44 Empty by default — the revocable switch, off until trust is granted.
45 """
46 
47 min_rate: float = 0.95
48 min_decided: int = 5
49 window_days: int = 90
50 allowlisted_repos: frozenset[str] = frozenset()
51 
52 
53class AutonomyVerdict(Frozen):
54 """Whether a PR would auto-merge under the grant, and the reason either way.
55 
56 Attributes:
57 would_merge: True iff every condition is met (advisory — nothing acts).
58 reason: The deciding factor — the grant met, or the first blocker.
59 """
60 
61 would_merge: bool
62 reason: str
63 
64 
65def class_earned(
66 decided: int, merged: int, policy: AutonomyPolicy
67) -> tuple[bool, str | None]:
68 """Whether a class has earned its gate move, and why not if it hasn't.
69 
70 Args:
71 decided: PRs of this class decided (merged or closed) in the window.
72 merged: How many of those were merged.
73 policy: The thresholds.
74 
75 Returns:
76 ``(earned, blocker)`` — ``blocker`` is ``None`` when earned, else the
77 short reason the gate has not moved.
78 """
79 if decided < policy.min_decided or decided == 0:
80 return False, f"only {decided}/{policy.min_decided} decided recently"
81 rate = merged / decided
82 if rate < policy.min_rate:
83 return False, (f"approval rate {rate:.0%} < {policy.min_rate:.0%}")
84 return True, None
85 
86 
87def pr_autonomy(
88 *,
89 repo: str,
90 verdict: str | None,
91 ci: str | None,
92 earned: bool,
93 blocker: str | None,
94 policy: AutonomyPolicy,
95) -> AutonomyVerdict:
96 """Whether one open PR would auto-merge under its class's grant.
97 
98 Reports the *substantive* blockers first — the earned grant, then this PR's
99 own clean-and-green conditions — and the steward's own revocable switch
100 (the allowlist) *last*. That ordering is what makes the shadow gate
101 watchable in its default, allowlist-off state: a held PR shows the real
102 thing to fix (``CI pending`` / ``class not earned``), and the bare
103 ``auto-merge not enabled`` reason appears only once a PR is otherwise fully
104 ready — i.e. exactly when flipping the switch would change the outcome.
105 ``would_merge`` still requires *every* condition, the allowlist included.
106 """
107 if not earned:
108 return AutonomyVerdict(
109 would_merge=False, reason=f"class not earned ({blocker})"
110 )
111 if verdict != "clean":
112 return AutonomyVerdict(
113 would_merge=False, reason=f"verdict is {verdict or 'unknown'}"
114 )
115 if ci != "passed":
116 return AutonomyVerdict(
117 would_merge=False, reason=f"CI {ci or 'pending'}"
118 )
119 if repo not in policy.allowlisted_repos:
120 return AutonomyVerdict(
121 would_merge=False, reason="auto-merge not enabled for this repo"
122 )
123 return AutonomyVerdict(
124 would_merge=True, reason="clean + green on an earned class"
125 )

Deriving each class's standing — read_model.py

The read-model joins GitHub outcomes to Temporal runs and now folds in the earned-autonomy figures. Two small helpers window the record to recent decisions (trust is recent, not lifetime): _decided_at dates a PR by its merge instant, falling back to its open time because a BumpRow carries no close timestamp; _within is the inclusive look-back test.

The windowing helpers — the closed-PR proxy is noted in the docstring, not hidden.

src/froot/dashboard/read_model.py · 587 lines
src/froot/dashboard/read_model.py587 lines · Python
⋯ 250 lines hidden (lines 1–250)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 BumpRow,
18 ClassGate,
19 DashboardModel,
20 Failure,
21 Judgment,
22 ReviewLoop,
23 ReviewRecord,
24 ReviewRow,
25 RunTelemetry,
26 ScanLoop,
27 SourceHealth,
28 TrackRecord,
29 Verification,
31from froot.domain.loop import Loop
32from froot.domain.repo import RepoRef, TargetRepo
33from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
34from froot.policy.naming import review_workflow_id, scan_workflow_id
35from froot.result import Ok
36 
37if TYPE_CHECKING:
38 from datetime import datetime
39 
40 from froot.dashboard.github_source import GithubPr
41 from froot.dashboard.temporal_source import (
42 BumpExecution,
43 PrReviewExecution,
44 ReviewExecution,
45 ScanExecution,
46 )
47 
48# The conservative fallback policy: empty allowlist, so the shadow gate holds
49# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
50_DEFAULT_POLICY = AutonomyPolicy()
51 
52_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
53# Every way a bump can end without closing cleanly — all belong in the honest
54# Failures panel, not silently dropped.
55_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
56 
57 
58def _median(values: list[float]) -> float | None:
59 """The median of ``values``, or ``None`` if empty (pure)."""
60 if not values:
61 return None
62 ordered = sorted(values)
63 mid = len(ordered) // 2
64 if len(ordered) % 2 == 1:
65 return ordered[mid]
66 return (ordered[mid - 1] + ordered[mid]) / 2
67 
68 
69def _scan_id(repo: str, loop: Loop) -> str | None:
70 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
71 match RepoRef.parse(repo):
72 case Ok(ref):
73 return scan_workflow_id(TargetRepo(repo=ref), loop)
74 case _:
75 return None
76 
77 
78def _scan_loops(
79 repos: tuple[str, ...],
80 loops: tuple[Loop, ...],
81 scans: tuple[ScanExecution, ...],
82) -> tuple[ScanLoop, ...]:
83 """One liveness row per configured (repo, loop) (latest execution wins)."""
84 by_id: dict[str, ScanExecution] = {}
85 for scan in scans:
86 current = by_id.get(scan.workflow_id)
87 if current is None or _newer(scan.start, current.start):
88 by_id[scan.workflow_id] = scan
89 rows: list[ScanLoop] = []
90 for loop in loops:
91 for repo in repos:
92 scan_id = _scan_id(repo, loop)
93 execution = by_id.get(scan_id) if scan_id is not None else None
94 if execution is None:
95 rows.append(
96 ScanLoop(
97 repo=repo,
98 loop=loop.value,
99 status="none",
100 live=False,
101 last_tick=None,
102 )
103 )
104 else:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status=execution.status,
110 live=execution.status in _LIVE_STATUSES,
111 last_tick=execution.start,
112 )
113 )
114 return tuple(rows)
115 
116 
117def _newer(a: datetime | None, b: datetime | None) -> bool:
118 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
119 if a is None:
120 return False
121 if b is None:
122 return True
123 return a > b
124 
125 
126def _bump_rows(
127 now: datetime,
128 prs: tuple[GithubPr, ...],
129 bumps: tuple[BumpExecution, ...],
130) -> tuple[BumpRow, ...]:
131 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number."""
132 # Keyed on (repo, PR number), not the bare number: Temporal lists every
133 # repo's bumps in the namespace, so two repos that each have a PR #N would
134 # otherwise cross-attribute one's verdict/CI onto the other's row.
135 by_pr: dict[tuple[str, int], BumpExecution] = {
136 (bump.repo, bump.pr_number): bump
137 for bump in bumps
138 if bump.repo is not None and bump.pr_number is not None
139 }
140 rows: list[BumpRow] = []
141 for pr in prs:
142 execution = by_pr.get((pr.repo, pr.number))
143 verdict = (execution.verdict if execution else None) or pr.verdict
144 ci = execution.ci if execution else None
145 ttm = _minutes_between(pr.opened_at, pr.merged_at)
146 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
147 rows.append(
148 BumpRow(
149 repo=pr.repo,
150 loop=pr.loop,
151 package=pr.package or "?",
152 from_version=pr.from_version,
153 to_version=pr.to_version or "?",
154 state=pr.state,
155 verdict=verdict,
156 ci=ci,
157 pr_number=pr.number,
158 pr_url=pr.url,
159 opened_at=pr.opened_at,
160 merged_at=pr.merged_at,
161 ttm_minutes=ttm,
162 age_hours=age,
163 )
164 )
165 rows.sort(key=_opened_sort_key, reverse=True)
166 return tuple(rows)
167 
168 
169def _opened_sort_key(row: BumpRow) -> float:
170 """Sort key putting the most recently opened PR first (unknown last)."""
171 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
172 
173 
174def _minutes_between(
175 start: datetime | None, end: datetime | None
176) -> float | None:
177 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
178 if start is None or end is None:
179 return None
180 return round((end - start).total_seconds() / 60, 1)
181 
182 
183def _hours_between(
184 start: datetime | None, end: datetime | None
185) -> float | None:
186 """Hours from ``start`` to ``end``, or ``None``."""
187 if start is None or end is None:
188 return None
189 return round((end - start).total_seconds() / 3600, 1)
190 
191 
192def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
193 """Counts, merge rate, and median time-to-merge from the bump rows."""
194 merged = [r for r in rows if r.state == "merged"]
195 closed = sum(1 for r in rows if r.state == "closed")
196 open_now = sum(1 for r in rows if r.state == "open")
197 decided = len(merged) + closed
198 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
199 return TrackRecord(
200 opened=len(rows),
201 merged=len(merged),
202 closed_unmerged=closed,
203 open_now=open_now,
204 merge_rate=(len(merged) / decided) if decided else None,
205 median_ttm_minutes=_median(ttms),
206 )
207 
208 
209def _verification(rows: tuple[BumpRow, ...]) -> Verification:
210 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
211 passed = sum(1 for r in rows if r.ci == "passed")
212 failed = sum(1 for r in rows if r.ci == "failed")
213 absent = sum(1 for r in rows if r.ci == "absent")
214 timed_out = sum(1 for r in rows if r.ci == "timed_out")
215 unknown = sum(1 for r in rows if r.ci is None)
216 return Verification(
217 passed=passed,
218 failed=failed,
219 absent=absent,
220 timed_out=timed_out,
221 unknown=unknown,
222 oracle_existed=passed + failed,
223 with_reading=passed + failed + absent + timed_out,
224 )
225 
226 
227def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
228 """The verdict mix plus the two calibration cells worth flagging."""
229 clean = sum(1 for r in rows if r.verdict == "clean")
230 risky = sum(1 for r in rows if r.verdict == "risky")
231 unknown = sum(1 for r in rows if r.verdict == "unknown")
232 none = sum(1 for r in rows if r.verdict is None)
233 clean_but_failed = sum(
234 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
235 )
236 flagged_but_passed = sum(
237 1
238 for r in rows
239 if r.verdict in ("risky", "unknown") and r.ci == "passed"
240 )
241 return Judgment(
242 clean=clean,
243 risky=risky,
244 unknown=unknown,
245 none=none,
246 clean_but_failed=clean_but_failed,
247 flagged_but_passed=flagged_but_passed,
248 )
249 
250 
251def _decided_at(row: BumpRow) -> datetime | None:
252 """When a decided PR was decided — merge time, else open time as a proxy.
253 
254 A merged PR has an exact merge instant; a closed-unmerged one does not (the
255 issues list froot reads carries no close timestamp), so it is windowed by
256 when it opened. The proxy only nudges a closed PR's window membership by its
257 own lifetime — close enough for a recency window measured in months.
258 """
259 return row.merged_at or row.opened_at
260 
261 
262def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
263 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
264 if when is None:
265 return False
266 return (now - when).total_seconds() <= window_days * 86400.0
267 
⋯ 320 lines hidden (lines 268–587)
268 
269def _class_gates(
270 now: datetime,
271 rows: tuple[BumpRow, ...],
272 repos: tuple[str, ...],
273 loops: tuple[Loop, ...],
274 policy: AutonomyPolicy,
275) -> tuple[ClassGate, ...]:
276 """The earned-autonomy standing of each (repo, loop) class (advisory).
277 
278 Counts only PRs *decided within the window* — trust is recent, not lifetime
279 (§2.11) — so a class that stops shipping decays back below its gate. The
280 budget figures (approvals / reclaim per week) translate the record into the
281 steward-time MHE actually meters (§3.6): what the class costs now, and what
282 moving its gate would hand back.
283 """
284 weeks = max(policy.window_days / 7.0, 1.0)
285 gates: list[ClassGate] = []
286 for loop in loops:
287 for repo in repos:
288 decided_rows = [
289 r
290 for r in rows
291 if r.repo == repo
292 and r.loop == loop.value
293 and r.state in ("merged", "closed")
294 and _within(_decided_at(r), now, policy.window_days)
295 ]
296 merged_rows = [r for r in decided_rows if r.state == "merged"]
297 decided = len(decided_rows)
298 merged = len(merged_rows)
299 earned, blocker = class_earned(decided, merged, policy)
300 # Reclaim is the budget a gate *move* hands back — so it is zero
301 # until the class has actually earned the move. Counting the
302 # clean-and-green merges of an un-earned class would imply savings
303 # the gate would refuse (every PR stays held at "class not earned").
304 reclaimable = (
305 sum(
306 1
307 for r in merged_rows
308 if r.verdict == "clean" and r.ci == "passed"
309 )
310 if earned
311 else 0
312 )
313 gates.append(
314 ClassGate(
315 repo=repo,
316 loop=loop.value,
317 decided=decided,
318 merged=merged,
319 merge_rate=(merged / decided) if decided else None,
320 earned=earned,
321 blocker=blocker,
322 approvals_per_week=round(merged / weeks, 2),
323 reclaim_per_week=round(reclaimable / weeks, 2),
324 window_days=policy.window_days,
325 )
326 )
327 return tuple(gates)
328 
329 
330def _gate(
331 rows: tuple[BumpRow, ...],
332 gates: tuple[ClassGate, ...],
333 policy: AutonomyPolicy,
334) -> tuple[BumpRow, ...]:
335 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
336 
337 The verdict is the shadow gate's: *would* this PR auto-merge under its
338 class's grant (advisory; nothing acts). The reason is the grant met, or the
339 first blocker to fix.
340 """
341 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
342 open_rows = [r for r in rows if r.state == "open"]
343 open_rows.sort(
344 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
345 reverse=True,
346 )
347 annotated: list[BumpRow] = []
348 for row in open_rows:
349 earned, blocker = earned_by_class.get(
350 (row.repo, row.loop), (False, "no record for this class")
351 )
352 verdict = pr_autonomy(
353 repo=row.repo,
354 verdict=row.verdict,
355 ci=row.ci,
356 earned=earned,
357 blocker=blocker,
358 policy=policy,
359 )
360 annotated.append(
361 row.model_copy(
362 update={
363 "would_auto_merge": verdict.would_merge,
364 "held_reason": (
365 None if verdict.would_merge else verdict.reason
366 ),
367 }
368 )
369 )
370 return tuple(annotated)
371 
372 
373def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
374 """Bump loops that did not close, newest first."""
375 failures = [
376 Failure(
377 workflow_id=bump.workflow_id,
378 kind=bump.status,
379 reason=bump.reason,
380 when=bump.close,
381 )
382 for bump in bumps
383 if bump.status in _FAILURE_STATUSES
384 ]
385 failures.sort(
386 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
387 reverse=True,
388 )
389 return tuple(failures)
390 
391 
392def _review_id(repo: str) -> str | None:
393 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
394 match RepoRef.parse(repo):
395 case Ok(ref):
396 return review_workflow_id(TargetRepo(repo=ref))
397 case _:
398 return None
399 
400 
401def _pr_review_prefix(repo: str) -> str | None:
402 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
403 review_id = _review_id(repo)
404 if review_id is None:
405 return None
406 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
407 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
408 
409 
410def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
411 """The configured repo a per-PR-review id belongs to (longest prefix)."""
412 best: str | None = None
413 best_len = -1
414 for repo in repos:
415 prefix = _pr_review_prefix(repo)
416 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
417 best, best_len = repo, len(prefix)
418 return best
419 
420 
421def _review_loops(
422 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
423) -> tuple[ReviewLoop, ...]:
424 """One liveness row per repo that actually has a review loop.
425 
426 Reviews are scoped to the Temporal repos, so a configured npm repo with no
427 review loop is omitted rather than shown as a dead one.
428 """
429 by_id: dict[str, ReviewExecution] = {}
430 for review in reviews:
431 current = by_id.get(review.workflow_id)
432 if current is None or _newer(review.start, current.start):
433 by_id[review.workflow_id] = review
434 loops: list[ReviewLoop] = []
435 for repo in repos:
436 review_id = _review_id(repo)
437 execution = by_id.get(review_id) if review_id is not None else None
438 if execution is None:
439 continue
440 loops.append(
441 ReviewLoop(
442 repo=repo,
443 status=execution.status,
444 live=execution.status in _LIVE_STATUSES,
445 last_tick=execution.start,
446 )
447 )
448 return tuple(loops)
449 
450 
451def _review_rows(
452 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
453) -> tuple[ReviewRow, ...]:
454 """Project each per-PR review into a row, newest review first."""
455 rows: list[ReviewRow] = []
456 for execution in pr_reviews:
457 repo = _attribute_repo(execution.workflow_id, repos)
458 pr_url = (
459 f"https://github.com/{repo}/pull/{execution.pr_number}"
460 if repo is not None and execution.pr_number is not None
461 else None
462 )
463 rows.append(
464 ReviewRow(
465 repo=repo or "?",
466 pr_number=execution.pr_number,
467 pr_url=pr_url,
468 head_sha=execution.head_sha,
469 findings=execution.findings,
470 rules=execution.rules,
471 comment_url=execution.comment_url,
472 status=execution.status,
473 reviewed_at=execution.close or execution.start,
474 )
475 )
476 rows.sort(
477 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
478 reverse=True,
479 )
480 return tuple(rows)
481 
482 
483def _review_record(
484 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
485) -> ReviewRecord:
486 """Counts over the completed reviews (resolved-rate is a later loop)."""
487 completed = [r for r in rows if r.status == "completed"]
488 flagged = sum(1 for r in completed if r.findings > 0)
489 hazards = sum(r.findings for r in completed)
490 return ReviewRecord(
491 reviewed=len(completed),
492 flagged=flagged,
493 clean=len(completed) - flagged,
494 hazards=hazards,
495 repos_covered=len(loops),
496 )
497 
498 
499def _sources(
500 github_error: str | None,
501 github_count: int,
502 temporal_error: str | None,
503 temporal_count: int,
504 telemetry: RunTelemetry,
505 clickhouse_error: str | None,
506) -> tuple[SourceHealth, ...]:
507 """Per-source health for the header strip."""
508 clickhouse_ok = clickhouse_error is None
509 if clickhouse_error == "off":
510 clickhouse_detail = "off"
511 elif clickhouse_error is not None:
512 clickhouse_detail = clickhouse_error
513 else:
514 spans = telemetry.total_spans
515 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
516 return (
517 SourceHealth(
518 name="github",
519 ok=github_error is None,
520 detail=github_error or f"{github_count} PRs",
521 ),
522 SourceHealth(
523 name="temporal",
524 ok=temporal_error is None,
525 detail=temporal_error or f"{temporal_count} workflows",
526 ),
527 SourceHealth(
528 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
529 ),
530 )
531 
532 
533def assemble(
534 *,
535 now: datetime,
536 repos: tuple[str, ...],
537 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
538 policy: AutonomyPolicy = _DEFAULT_POLICY,
539 scan_interval_seconds: int,
540 review_interval_seconds: int,
541 github: tuple[tuple[GithubPr, ...], str | None],
542 temporal: tuple[
543 tuple[
544 tuple[ScanExecution, ...],
545 tuple[BumpExecution, ...],
546 tuple[ReviewExecution, ...],
547 tuple[PrReviewExecution, ...],
548 ],
549 str | None,
550 ],
551 telemetry: tuple[RunTelemetry, str | None],
552) -> DashboardModel:
553 """Build the whole view from the readers' ``(data, error)`` outputs."""
554 prs, github_error = github
555 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
556 run_telemetry, clickhouse_error = telemetry
557 
558 rows = _bump_rows(now, prs, bumps)
559 class_gates = _class_gates(now, rows, repos, loops, policy)
560 review_loops = _review_loops(repos, reviews)
561 review_rows = _review_rows(pr_reviews, repos)
562 return DashboardModel(
563 generated_at=now,
564 repos_configured=repos,
565 scan_interval_seconds=scan_interval_seconds,
566 sources=_sources(
567 github_error,
568 len(prs),
569 temporal_error,
570 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
571 run_telemetry,
572 clickhouse_error,
573 ),
574 scan_loops=_scan_loops(repos, loops, scans),
575 track_record=_track_record(rows),
576 class_gates=class_gates,
577 verification=_verification(rows),
578 judgment=_judgment(rows),
579 gate=_gate(rows, class_gates, policy),
580 bumps=rows,
581 failures=_failures(bumps),
582 review_interval_seconds=review_interval_seconds,
583 review_loops=review_loops,
584 review_record=_review_record(review_loops, review_rows),
585 reviews=review_rows,
586 telemetry=run_telemetry,
587 )

_class_gates builds one ClassGate per configured (repo, loop): the windowed decided/merged counts, the merge rate, the earned verdict, and a steward-time budget — approvals/week the class costs now, and the clean-and-green slice a gate move would reclaim.

Note the spotlight: reclaim is zeroed unless the class is earned.

src/froot/dashboard/read_model.py · 587 lines
src/froot/dashboard/read_model.py587 lines · Python
⋯ 268 lines hidden (lines 1–268)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 BumpRow,
18 ClassGate,
19 DashboardModel,
20 Failure,
21 Judgment,
22 ReviewLoop,
23 ReviewRecord,
24 ReviewRow,
25 RunTelemetry,
26 ScanLoop,
27 SourceHealth,
28 TrackRecord,
29 Verification,
31from froot.domain.loop import Loop
32from froot.domain.repo import RepoRef, TargetRepo
33from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
34from froot.policy.naming import review_workflow_id, scan_workflow_id
35from froot.result import Ok
36 
37if TYPE_CHECKING:
38 from datetime import datetime
39 
40 from froot.dashboard.github_source import GithubPr
41 from froot.dashboard.temporal_source import (
42 BumpExecution,
43 PrReviewExecution,
44 ReviewExecution,
45 ScanExecution,
46 )
47 
48# The conservative fallback policy: empty allowlist, so the shadow gate holds
49# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
50_DEFAULT_POLICY = AutonomyPolicy()
51 
52_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
53# Every way a bump can end without closing cleanly — all belong in the honest
54# Failures panel, not silently dropped.
55_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
56 
57 
58def _median(values: list[float]) -> float | None:
59 """The median of ``values``, or ``None`` if empty (pure)."""
60 if not values:
61 return None
62 ordered = sorted(values)
63 mid = len(ordered) // 2
64 if len(ordered) % 2 == 1:
65 return ordered[mid]
66 return (ordered[mid - 1] + ordered[mid]) / 2
67 
68 
69def _scan_id(repo: str, loop: Loop) -> str | None:
70 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
71 match RepoRef.parse(repo):
72 case Ok(ref):
73 return scan_workflow_id(TargetRepo(repo=ref), loop)
74 case _:
75 return None
76 
77 
78def _scan_loops(
79 repos: tuple[str, ...],
80 loops: tuple[Loop, ...],
81 scans: tuple[ScanExecution, ...],
82) -> tuple[ScanLoop, ...]:
83 """One liveness row per configured (repo, loop) (latest execution wins)."""
84 by_id: dict[str, ScanExecution] = {}
85 for scan in scans:
86 current = by_id.get(scan.workflow_id)
87 if current is None or _newer(scan.start, current.start):
88 by_id[scan.workflow_id] = scan
89 rows: list[ScanLoop] = []
90 for loop in loops:
91 for repo in repos:
92 scan_id = _scan_id(repo, loop)
93 execution = by_id.get(scan_id) if scan_id is not None else None
94 if execution is None:
95 rows.append(
96 ScanLoop(
97 repo=repo,
98 loop=loop.value,
99 status="none",
100 live=False,
101 last_tick=None,
102 )
103 )
104 else:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status=execution.status,
110 live=execution.status in _LIVE_STATUSES,
111 last_tick=execution.start,
112 )
113 )
114 return tuple(rows)
115 
116 
117def _newer(a: datetime | None, b: datetime | None) -> bool:
118 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
119 if a is None:
120 return False
121 if b is None:
122 return True
123 return a > b
124 
125 
126def _bump_rows(
127 now: datetime,
128 prs: tuple[GithubPr, ...],
129 bumps: tuple[BumpExecution, ...],
130) -> tuple[BumpRow, ...]:
131 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number."""
132 # Keyed on (repo, PR number), not the bare number: Temporal lists every
133 # repo's bumps in the namespace, so two repos that each have a PR #N would
134 # otherwise cross-attribute one's verdict/CI onto the other's row.
135 by_pr: dict[tuple[str, int], BumpExecution] = {
136 (bump.repo, bump.pr_number): bump
137 for bump in bumps
138 if bump.repo is not None and bump.pr_number is not None
139 }
140 rows: list[BumpRow] = []
141 for pr in prs:
142 execution = by_pr.get((pr.repo, pr.number))
143 verdict = (execution.verdict if execution else None) or pr.verdict
144 ci = execution.ci if execution else None
145 ttm = _minutes_between(pr.opened_at, pr.merged_at)
146 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
147 rows.append(
148 BumpRow(
149 repo=pr.repo,
150 loop=pr.loop,
151 package=pr.package or "?",
152 from_version=pr.from_version,
153 to_version=pr.to_version or "?",
154 state=pr.state,
155 verdict=verdict,
156 ci=ci,
157 pr_number=pr.number,
158 pr_url=pr.url,
159 opened_at=pr.opened_at,
160 merged_at=pr.merged_at,
161 ttm_minutes=ttm,
162 age_hours=age,
163 )
164 )
165 rows.sort(key=_opened_sort_key, reverse=True)
166 return tuple(rows)
167 
168 
169def _opened_sort_key(row: BumpRow) -> float:
170 """Sort key putting the most recently opened PR first (unknown last)."""
171 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
172 
173 
174def _minutes_between(
175 start: datetime | None, end: datetime | None
176) -> float | None:
177 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
178 if start is None or end is None:
179 return None
180 return round((end - start).total_seconds() / 60, 1)
181 
182 
183def _hours_between(
184 start: datetime | None, end: datetime | None
185) -> float | None:
186 """Hours from ``start`` to ``end``, or ``None``."""
187 if start is None or end is None:
188 return None
189 return round((end - start).total_seconds() / 3600, 1)
190 
191 
192def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
193 """Counts, merge rate, and median time-to-merge from the bump rows."""
194 merged = [r for r in rows if r.state == "merged"]
195 closed = sum(1 for r in rows if r.state == "closed")
196 open_now = sum(1 for r in rows if r.state == "open")
197 decided = len(merged) + closed
198 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
199 return TrackRecord(
200 opened=len(rows),
201 merged=len(merged),
202 closed_unmerged=closed,
203 open_now=open_now,
204 merge_rate=(len(merged) / decided) if decided else None,
205 median_ttm_minutes=_median(ttms),
206 )
207 
208 
209def _verification(rows: tuple[BumpRow, ...]) -> Verification:
210 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
211 passed = sum(1 for r in rows if r.ci == "passed")
212 failed = sum(1 for r in rows if r.ci == "failed")
213 absent = sum(1 for r in rows if r.ci == "absent")
214 timed_out = sum(1 for r in rows if r.ci == "timed_out")
215 unknown = sum(1 for r in rows if r.ci is None)
216 return Verification(
217 passed=passed,
218 failed=failed,
219 absent=absent,
220 timed_out=timed_out,
221 unknown=unknown,
222 oracle_existed=passed + failed,
223 with_reading=passed + failed + absent + timed_out,
224 )
225 
226 
227def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
228 """The verdict mix plus the two calibration cells worth flagging."""
229 clean = sum(1 for r in rows if r.verdict == "clean")
230 risky = sum(1 for r in rows if r.verdict == "risky")
231 unknown = sum(1 for r in rows if r.verdict == "unknown")
232 none = sum(1 for r in rows if r.verdict is None)
233 clean_but_failed = sum(
234 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
235 )
236 flagged_but_passed = sum(
237 1
238 for r in rows
239 if r.verdict in ("risky", "unknown") and r.ci == "passed"
240 )
241 return Judgment(
242 clean=clean,
243 risky=risky,
244 unknown=unknown,
245 none=none,
246 clean_but_failed=clean_but_failed,
247 flagged_but_passed=flagged_but_passed,
248 )
249 
250 
251def _decided_at(row: BumpRow) -> datetime | None:
252 """When a decided PR was decided — merge time, else open time as a proxy.
253 
254 A merged PR has an exact merge instant; a closed-unmerged one does not (the
255 issues list froot reads carries no close timestamp), so it is windowed by
256 when it opened. The proxy only nudges a closed PR's window membership by its
257 own lifetime — close enough for a recency window measured in months.
258 """
259 return row.merged_at or row.opened_at
260 
261 
262def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
263 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
264 if when is None:
265 return False
266 return (now - when).total_seconds() <= window_days * 86400.0
267 
268 
269def _class_gates(
270 now: datetime,
271 rows: tuple[BumpRow, ...],
272 repos: tuple[str, ...],
273 loops: tuple[Loop, ...],
274 policy: AutonomyPolicy,
275) -> tuple[ClassGate, ...]:
276 """The earned-autonomy standing of each (repo, loop) class (advisory).
277 
278 Counts only PRs *decided within the window* — trust is recent, not lifetime
279 (§2.11) — so a class that stops shipping decays back below its gate. The
280 budget figures (approvals / reclaim per week) translate the record into the
281 steward-time MHE actually meters (§3.6): what the class costs now, and what
282 moving its gate would hand back.
283 """
284 weeks = max(policy.window_days / 7.0, 1.0)
285 gates: list[ClassGate] = []
286 for loop in loops:
287 for repo in repos:
288 decided_rows = [
289 r
290 for r in rows
291 if r.repo == repo
292 and r.loop == loop.value
293 and r.state in ("merged", "closed")
294 and _within(_decided_at(r), now, policy.window_days)
295 ]
296 merged_rows = [r for r in decided_rows if r.state == "merged"]
297 decided = len(decided_rows)
298 merged = len(merged_rows)
299 earned, blocker = class_earned(decided, merged, policy)
300 # Reclaim is the budget a gate *move* hands back — so it is zero
301 # until the class has actually earned the move. Counting the
302 # clean-and-green merges of an un-earned class would imply savings
303 # the gate would refuse (every PR stays held at "class not earned").
304 reclaimable = (
305 sum(
306 1
307 for r in merged_rows
308 if r.verdict == "clean" and r.ci == "passed"
309 )
310 if earned
311 else 0
312 )
313 gates.append(
314 ClassGate(
315 repo=repo,
316 loop=loop.value,
317 decided=decided,
318 merged=merged,
319 merge_rate=(merged / decided) if decided else None,
320 earned=earned,
321 blocker=blocker,
322 approvals_per_week=round(merged / weeks, 2),
323 reclaim_per_week=round(reclaimable / weeks, 2),
324 window_days=policy.window_days,
325 )
326 )
327 return tuple(gates)
⋯ 260 lines hidden (lines 328–587)
328 
329 
330def _gate(
331 rows: tuple[BumpRow, ...],
332 gates: tuple[ClassGate, ...],
333 policy: AutonomyPolicy,
334) -> tuple[BumpRow, ...]:
335 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
336 
337 The verdict is the shadow gate's: *would* this PR auto-merge under its
338 class's grant (advisory; nothing acts). The reason is the grant met, or the
339 first blocker to fix.
340 """
341 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
342 open_rows = [r for r in rows if r.state == "open"]
343 open_rows.sort(
344 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
345 reverse=True,
346 )
347 annotated: list[BumpRow] = []
348 for row in open_rows:
349 earned, blocker = earned_by_class.get(
350 (row.repo, row.loop), (False, "no record for this class")
351 )
352 verdict = pr_autonomy(
353 repo=row.repo,
354 verdict=row.verdict,
355 ci=row.ci,
356 earned=earned,
357 blocker=blocker,
358 policy=policy,
359 )
360 annotated.append(
361 row.model_copy(
362 update={
363 "would_auto_merge": verdict.would_merge,
364 "held_reason": (
365 None if verdict.would_merge else verdict.reason
366 ),
367 }
368 )
369 )
370 return tuple(annotated)
371 
372 
373def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
374 """Bump loops that did not close, newest first."""
375 failures = [
376 Failure(
377 workflow_id=bump.workflow_id,
378 kind=bump.status,
379 reason=bump.reason,
380 when=bump.close,
381 )
382 for bump in bumps
383 if bump.status in _FAILURE_STATUSES
384 ]
385 failures.sort(
386 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
387 reverse=True,
388 )
389 return tuple(failures)
390 
391 
392def _review_id(repo: str) -> str | None:
393 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
394 match RepoRef.parse(repo):
395 case Ok(ref):
396 return review_workflow_id(TargetRepo(repo=ref))
397 case _:
398 return None
399 
400 
401def _pr_review_prefix(repo: str) -> str | None:
402 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
403 review_id = _review_id(repo)
404 if review_id is None:
405 return None
406 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
407 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
408 
409 
410def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
411 """The configured repo a per-PR-review id belongs to (longest prefix)."""
412 best: str | None = None
413 best_len = -1
414 for repo in repos:
415 prefix = _pr_review_prefix(repo)
416 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
417 best, best_len = repo, len(prefix)
418 return best
419 
420 
421def _review_loops(
422 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
423) -> tuple[ReviewLoop, ...]:
424 """One liveness row per repo that actually has a review loop.
425 
426 Reviews are scoped to the Temporal repos, so a configured npm repo with no
427 review loop is omitted rather than shown as a dead one.
428 """
429 by_id: dict[str, ReviewExecution] = {}
430 for review in reviews:
431 current = by_id.get(review.workflow_id)
432 if current is None or _newer(review.start, current.start):
433 by_id[review.workflow_id] = review
434 loops: list[ReviewLoop] = []
435 for repo in repos:
436 review_id = _review_id(repo)
437 execution = by_id.get(review_id) if review_id is not None else None
438 if execution is None:
439 continue
440 loops.append(
441 ReviewLoop(
442 repo=repo,
443 status=execution.status,
444 live=execution.status in _LIVE_STATUSES,
445 last_tick=execution.start,
446 )
447 )
448 return tuple(loops)
449 
450 
451def _review_rows(
452 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
453) -> tuple[ReviewRow, ...]:
454 """Project each per-PR review into a row, newest review first."""
455 rows: list[ReviewRow] = []
456 for execution in pr_reviews:
457 repo = _attribute_repo(execution.workflow_id, repos)
458 pr_url = (
459 f"https://github.com/{repo}/pull/{execution.pr_number}"
460 if repo is not None and execution.pr_number is not None
461 else None
462 )
463 rows.append(
464 ReviewRow(
465 repo=repo or "?",
466 pr_number=execution.pr_number,
467 pr_url=pr_url,
468 head_sha=execution.head_sha,
469 findings=execution.findings,
470 rules=execution.rules,
471 comment_url=execution.comment_url,
472 status=execution.status,
473 reviewed_at=execution.close or execution.start,
474 )
475 )
476 rows.sort(
477 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
478 reverse=True,
479 )
480 return tuple(rows)
481 
482 
483def _review_record(
484 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
485) -> ReviewRecord:
486 """Counts over the completed reviews (resolved-rate is a later loop)."""
487 completed = [r for r in rows if r.status == "completed"]
488 flagged = sum(1 for r in completed if r.findings > 0)
489 hazards = sum(r.findings for r in completed)
490 return ReviewRecord(
491 reviewed=len(completed),
492 flagged=flagged,
493 clean=len(completed) - flagged,
494 hazards=hazards,
495 repos_covered=len(loops),
496 )
497 
498 
499def _sources(
500 github_error: str | None,
501 github_count: int,
502 temporal_error: str | None,
503 temporal_count: int,
504 telemetry: RunTelemetry,
505 clickhouse_error: str | None,
506) -> tuple[SourceHealth, ...]:
507 """Per-source health for the header strip."""
508 clickhouse_ok = clickhouse_error is None
509 if clickhouse_error == "off":
510 clickhouse_detail = "off"
511 elif clickhouse_error is not None:
512 clickhouse_detail = clickhouse_error
513 else:
514 spans = telemetry.total_spans
515 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
516 return (
517 SourceHealth(
518 name="github",
519 ok=github_error is None,
520 detail=github_error or f"{github_count} PRs",
521 ),
522 SourceHealth(
523 name="temporal",
524 ok=temporal_error is None,
525 detail=temporal_error or f"{temporal_count} workflows",
526 ),
527 SourceHealth(
528 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
529 ),
530 )
531 
532 
533def assemble(
534 *,
535 now: datetime,
536 repos: tuple[str, ...],
537 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
538 policy: AutonomyPolicy = _DEFAULT_POLICY,
539 scan_interval_seconds: int,
540 review_interval_seconds: int,
541 github: tuple[tuple[GithubPr, ...], str | None],
542 temporal: tuple[
543 tuple[
544 tuple[ScanExecution, ...],
545 tuple[BumpExecution, ...],
546 tuple[ReviewExecution, ...],
547 tuple[PrReviewExecution, ...],
548 ],
549 str | None,
550 ],
551 telemetry: tuple[RunTelemetry, str | None],
552) -> DashboardModel:
553 """Build the whole view from the readers' ``(data, error)`` outputs."""
554 prs, github_error = github
555 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
556 run_telemetry, clickhouse_error = telemetry
557 
558 rows = _bump_rows(now, prs, bumps)
559 class_gates = _class_gates(now, rows, repos, loops, policy)
560 review_loops = _review_loops(repos, reviews)
561 review_rows = _review_rows(pr_reviews, repos)
562 return DashboardModel(
563 generated_at=now,
564 repos_configured=repos,
565 scan_interval_seconds=scan_interval_seconds,
566 sources=_sources(
567 github_error,
568 len(prs),
569 temporal_error,
570 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
571 run_telemetry,
572 clickhouse_error,
573 ),
574 scan_loops=_scan_loops(repos, loops, scans),
575 track_record=_track_record(rows),
576 class_gates=class_gates,
577 verification=_verification(rows),
578 judgment=_judgment(rows),
579 gate=_gate(rows, class_gates, policy),
580 bumps=rows,
581 failures=_failures(bumps),
582 review_interval_seconds=review_interval_seconds,
583 review_loops=review_loops,
584 review_record=_review_record(review_loops, review_rows),
585 reviews=review_rows,
586 telemetry=run_telemetry,
587 )

_gate annotates each open PR with the shadow verdict via pr_autonomy + an immutable model_copy.

src/froot/dashboard/read_model.py · 587 lines
src/froot/dashboard/read_model.py587 lines · Python
⋯ 329 lines hidden (lines 1–329)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 BumpRow,
18 ClassGate,
19 DashboardModel,
20 Failure,
21 Judgment,
22 ReviewLoop,
23 ReviewRecord,
24 ReviewRow,
25 RunTelemetry,
26 ScanLoop,
27 SourceHealth,
28 TrackRecord,
29 Verification,
31from froot.domain.loop import Loop
32from froot.domain.repo import RepoRef, TargetRepo
33from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
34from froot.policy.naming import review_workflow_id, scan_workflow_id
35from froot.result import Ok
36 
37if TYPE_CHECKING:
38 from datetime import datetime
39 
40 from froot.dashboard.github_source import GithubPr
41 from froot.dashboard.temporal_source import (
42 BumpExecution,
43 PrReviewExecution,
44 ReviewExecution,
45 ScanExecution,
46 )
47 
48# The conservative fallback policy: empty allowlist, so the shadow gate holds
49# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
50_DEFAULT_POLICY = AutonomyPolicy()
51 
52_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
53# Every way a bump can end without closing cleanly — all belong in the honest
54# Failures panel, not silently dropped.
55_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
56 
57 
58def _median(values: list[float]) -> float | None:
59 """The median of ``values``, or ``None`` if empty (pure)."""
60 if not values:
61 return None
62 ordered = sorted(values)
63 mid = len(ordered) // 2
64 if len(ordered) % 2 == 1:
65 return ordered[mid]
66 return (ordered[mid - 1] + ordered[mid]) / 2
67 
68 
69def _scan_id(repo: str, loop: Loop) -> str | None:
70 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
71 match RepoRef.parse(repo):
72 case Ok(ref):
73 return scan_workflow_id(TargetRepo(repo=ref), loop)
74 case _:
75 return None
76 
77 
78def _scan_loops(
79 repos: tuple[str, ...],
80 loops: tuple[Loop, ...],
81 scans: tuple[ScanExecution, ...],
82) -> tuple[ScanLoop, ...]:
83 """One liveness row per configured (repo, loop) (latest execution wins)."""
84 by_id: dict[str, ScanExecution] = {}
85 for scan in scans:
86 current = by_id.get(scan.workflow_id)
87 if current is None or _newer(scan.start, current.start):
88 by_id[scan.workflow_id] = scan
89 rows: list[ScanLoop] = []
90 for loop in loops:
91 for repo in repos:
92 scan_id = _scan_id(repo, loop)
93 execution = by_id.get(scan_id) if scan_id is not None else None
94 if execution is None:
95 rows.append(
96 ScanLoop(
97 repo=repo,
98 loop=loop.value,
99 status="none",
100 live=False,
101 last_tick=None,
102 )
103 )
104 else:
105 rows.append(
106 ScanLoop(
107 repo=repo,
108 loop=loop.value,
109 status=execution.status,
110 live=execution.status in _LIVE_STATUSES,
111 last_tick=execution.start,
112 )
113 )
114 return tuple(rows)
115 
116 
117def _newer(a: datetime | None, b: datetime | None) -> bool:
118 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
119 if a is None:
120 return False
121 if b is None:
122 return True
123 return a > b
124 
125 
126def _bump_rows(
127 now: datetime,
128 prs: tuple[GithubPr, ...],
129 bumps: tuple[BumpExecution, ...],
130) -> tuple[BumpRow, ...]:
131 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number."""
132 # Keyed on (repo, PR number), not the bare number: Temporal lists every
133 # repo's bumps in the namespace, so two repos that each have a PR #N would
134 # otherwise cross-attribute one's verdict/CI onto the other's row.
135 by_pr: dict[tuple[str, int], BumpExecution] = {
136 (bump.repo, bump.pr_number): bump
137 for bump in bumps
138 if bump.repo is not None and bump.pr_number is not None
139 }
140 rows: list[BumpRow] = []
141 for pr in prs:
142 execution = by_pr.get((pr.repo, pr.number))
143 verdict = (execution.verdict if execution else None) or pr.verdict
144 ci = execution.ci if execution else None
145 ttm = _minutes_between(pr.opened_at, pr.merged_at)
146 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
147 rows.append(
148 BumpRow(
149 repo=pr.repo,
150 loop=pr.loop,
151 package=pr.package or "?",
152 from_version=pr.from_version,
153 to_version=pr.to_version or "?",
154 state=pr.state,
155 verdict=verdict,
156 ci=ci,
157 pr_number=pr.number,
158 pr_url=pr.url,
159 opened_at=pr.opened_at,
160 merged_at=pr.merged_at,
161 ttm_minutes=ttm,
162 age_hours=age,
163 )
164 )
165 rows.sort(key=_opened_sort_key, reverse=True)
166 return tuple(rows)
167 
168 
169def _opened_sort_key(row: BumpRow) -> float:
170 """Sort key putting the most recently opened PR first (unknown last)."""
171 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
172 
173 
174def _minutes_between(
175 start: datetime | None, end: datetime | None
176) -> float | None:
177 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
178 if start is None or end is None:
179 return None
180 return round((end - start).total_seconds() / 60, 1)
181 
182 
183def _hours_between(
184 start: datetime | None, end: datetime | None
185) -> float | None:
186 """Hours from ``start`` to ``end``, or ``None``."""
187 if start is None or end is None:
188 return None
189 return round((end - start).total_seconds() / 3600, 1)
190 
191 
192def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
193 """Counts, merge rate, and median time-to-merge from the bump rows."""
194 merged = [r for r in rows if r.state == "merged"]
195 closed = sum(1 for r in rows if r.state == "closed")
196 open_now = sum(1 for r in rows if r.state == "open")
197 decided = len(merged) + closed
198 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
199 return TrackRecord(
200 opened=len(rows),
201 merged=len(merged),
202 closed_unmerged=closed,
203 open_now=open_now,
204 merge_rate=(len(merged) / decided) if decided else None,
205 median_ttm_minutes=_median(ttms),
206 )
207 
208 
209def _verification(rows: tuple[BumpRow, ...]) -> Verification:
210 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
211 passed = sum(1 for r in rows if r.ci == "passed")
212 failed = sum(1 for r in rows if r.ci == "failed")
213 absent = sum(1 for r in rows if r.ci == "absent")
214 timed_out = sum(1 for r in rows if r.ci == "timed_out")
215 unknown = sum(1 for r in rows if r.ci is None)
216 return Verification(
217 passed=passed,
218 failed=failed,
219 absent=absent,
220 timed_out=timed_out,
221 unknown=unknown,
222 oracle_existed=passed + failed,
223 with_reading=passed + failed + absent + timed_out,
224 )
225 
226 
227def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
228 """The verdict mix plus the two calibration cells worth flagging."""
229 clean = sum(1 for r in rows if r.verdict == "clean")
230 risky = sum(1 for r in rows if r.verdict == "risky")
231 unknown = sum(1 for r in rows if r.verdict == "unknown")
232 none = sum(1 for r in rows if r.verdict is None)
233 clean_but_failed = sum(
234 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
235 )
236 flagged_but_passed = sum(
237 1
238 for r in rows
239 if r.verdict in ("risky", "unknown") and r.ci == "passed"
240 )
241 return Judgment(
242 clean=clean,
243 risky=risky,
244 unknown=unknown,
245 none=none,
246 clean_but_failed=clean_but_failed,
247 flagged_but_passed=flagged_but_passed,
248 )
249 
250 
251def _decided_at(row: BumpRow) -> datetime | None:
252 """When a decided PR was decided — merge time, else open time as a proxy.
253 
254 A merged PR has an exact merge instant; a closed-unmerged one does not (the
255 issues list froot reads carries no close timestamp), so it is windowed by
256 when it opened. The proxy only nudges a closed PR's window membership by its
257 own lifetime — close enough for a recency window measured in months.
258 """
259 return row.merged_at or row.opened_at
260 
261 
262def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
263 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
264 if when is None:
265 return False
266 return (now - when).total_seconds() <= window_days * 86400.0
267 
268 
269def _class_gates(
270 now: datetime,
271 rows: tuple[BumpRow, ...],
272 repos: tuple[str, ...],
273 loops: tuple[Loop, ...],
274 policy: AutonomyPolicy,
275) -> tuple[ClassGate, ...]:
276 """The earned-autonomy standing of each (repo, loop) class (advisory).
277 
278 Counts only PRs *decided within the window* — trust is recent, not lifetime
279 (§2.11) — so a class that stops shipping decays back below its gate. The
280 budget figures (approvals / reclaim per week) translate the record into the
281 steward-time MHE actually meters (§3.6): what the class costs now, and what
282 moving its gate would hand back.
283 """
284 weeks = max(policy.window_days / 7.0, 1.0)
285 gates: list[ClassGate] = []
286 for loop in loops:
287 for repo in repos:
288 decided_rows = [
289 r
290 for r in rows
291 if r.repo == repo
292 and r.loop == loop.value
293 and r.state in ("merged", "closed")
294 and _within(_decided_at(r), now, policy.window_days)
295 ]
296 merged_rows = [r for r in decided_rows if r.state == "merged"]
297 decided = len(decided_rows)
298 merged = len(merged_rows)
299 earned, blocker = class_earned(decided, merged, policy)
300 # Reclaim is the budget a gate *move* hands back — so it is zero
301 # until the class has actually earned the move. Counting the
302 # clean-and-green merges of an un-earned class would imply savings
303 # the gate would refuse (every PR stays held at "class not earned").
304 reclaimable = (
305 sum(
306 1
307 for r in merged_rows
308 if r.verdict == "clean" and r.ci == "passed"
309 )
310 if earned
311 else 0
312 )
313 gates.append(
314 ClassGate(
315 repo=repo,
316 loop=loop.value,
317 decided=decided,
318 merged=merged,
319 merge_rate=(merged / decided) if decided else None,
320 earned=earned,
321 blocker=blocker,
322 approvals_per_week=round(merged / weeks, 2),
323 reclaim_per_week=round(reclaimable / weeks, 2),
324 window_days=policy.window_days,
325 )
326 )
327 return tuple(gates)
328 
329 
330def _gate(
331 rows: tuple[BumpRow, ...],
332 gates: tuple[ClassGate, ...],
333 policy: AutonomyPolicy,
334) -> tuple[BumpRow, ...]:
335 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
336 
337 The verdict is the shadow gate's: *would* this PR auto-merge under its
338 class's grant (advisory; nothing acts). The reason is the grant met, or the
339 first blocker to fix.
340 """
341 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
342 open_rows = [r for r in rows if r.state == "open"]
343 open_rows.sort(
344 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
345 reverse=True,
346 )
347 annotated: list[BumpRow] = []
348 for row in open_rows:
349 earned, blocker = earned_by_class.get(
350 (row.repo, row.loop), (False, "no record for this class")
351 )
352 verdict = pr_autonomy(
353 repo=row.repo,
354 verdict=row.verdict,
355 ci=row.ci,
356 earned=earned,
357 blocker=blocker,
358 policy=policy,
359 )
360 annotated.append(
361 row.model_copy(
362 update={
363 "would_auto_merge": verdict.would_merge,
364 "held_reason": (
365 None if verdict.would_merge else verdict.reason
366 ),
367 }
368 )
369 )
370 return tuple(annotated)
⋯ 217 lines hidden (lines 371–587)
371 
372 
373def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
374 """Bump loops that did not close, newest first."""
375 failures = [
376 Failure(
377 workflow_id=bump.workflow_id,
378 kind=bump.status,
379 reason=bump.reason,
380 when=bump.close,
381 )
382 for bump in bumps
383 if bump.status in _FAILURE_STATUSES
384 ]
385 failures.sort(
386 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
387 reverse=True,
388 )
389 return tuple(failures)
390 
391 
392def _review_id(repo: str) -> str | None:
393 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
394 match RepoRef.parse(repo):
395 case Ok(ref):
396 return review_workflow_id(TargetRepo(repo=ref))
397 case _:
398 return None
399 
400 
401def _pr_review_prefix(repo: str) -> str | None:
402 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
403 review_id = _review_id(repo)
404 if review_id is None:
405 return None
406 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
407 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
408 
409 
410def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
411 """The configured repo a per-PR-review id belongs to (longest prefix)."""
412 best: str | None = None
413 best_len = -1
414 for repo in repos:
415 prefix = _pr_review_prefix(repo)
416 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
417 best, best_len = repo, len(prefix)
418 return best
419 
420 
421def _review_loops(
422 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
423) -> tuple[ReviewLoop, ...]:
424 """One liveness row per repo that actually has a review loop.
425 
426 Reviews are scoped to the Temporal repos, so a configured npm repo with no
427 review loop is omitted rather than shown as a dead one.
428 """
429 by_id: dict[str, ReviewExecution] = {}
430 for review in reviews:
431 current = by_id.get(review.workflow_id)
432 if current is None or _newer(review.start, current.start):
433 by_id[review.workflow_id] = review
434 loops: list[ReviewLoop] = []
435 for repo in repos:
436 review_id = _review_id(repo)
437 execution = by_id.get(review_id) if review_id is not None else None
438 if execution is None:
439 continue
440 loops.append(
441 ReviewLoop(
442 repo=repo,
443 status=execution.status,
444 live=execution.status in _LIVE_STATUSES,
445 last_tick=execution.start,
446 )
447 )
448 return tuple(loops)
449 
450 
451def _review_rows(
452 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
453) -> tuple[ReviewRow, ...]:
454 """Project each per-PR review into a row, newest review first."""
455 rows: list[ReviewRow] = []
456 for execution in pr_reviews:
457 repo = _attribute_repo(execution.workflow_id, repos)
458 pr_url = (
459 f"https://github.com/{repo}/pull/{execution.pr_number}"
460 if repo is not None and execution.pr_number is not None
461 else None
462 )
463 rows.append(
464 ReviewRow(
465 repo=repo or "?",
466 pr_number=execution.pr_number,
467 pr_url=pr_url,
468 head_sha=execution.head_sha,
469 findings=execution.findings,
470 rules=execution.rules,
471 comment_url=execution.comment_url,
472 status=execution.status,
473 reviewed_at=execution.close or execution.start,
474 )
475 )
476 rows.sort(
477 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
478 reverse=True,
479 )
480 return tuple(rows)
481 
482 
483def _review_record(
484 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
485) -> ReviewRecord:
486 """Counts over the completed reviews (resolved-rate is a later loop)."""
487 completed = [r for r in rows if r.status == "completed"]
488 flagged = sum(1 for r in completed if r.findings > 0)
489 hazards = sum(r.findings for r in completed)
490 return ReviewRecord(
491 reviewed=len(completed),
492 flagged=flagged,
493 clean=len(completed) - flagged,
494 hazards=hazards,
495 repos_covered=len(loops),
496 )
497 
498 
499def _sources(
500 github_error: str | None,
501 github_count: int,
502 temporal_error: str | None,
503 temporal_count: int,
504 telemetry: RunTelemetry,
505 clickhouse_error: str | None,
506) -> tuple[SourceHealth, ...]:
507 """Per-source health for the header strip."""
508 clickhouse_ok = clickhouse_error is None
509 if clickhouse_error == "off":
510 clickhouse_detail = "off"
511 elif clickhouse_error is not None:
512 clickhouse_detail = clickhouse_error
513 else:
514 spans = telemetry.total_spans
515 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
516 return (
517 SourceHealth(
518 name="github",
519 ok=github_error is None,
520 detail=github_error or f"{github_count} PRs",
521 ),
522 SourceHealth(
523 name="temporal",
524 ok=temporal_error is None,
525 detail=temporal_error or f"{temporal_count} workflows",
526 ),
527 SourceHealth(
528 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
529 ),
530 )
531 
532 
533def assemble(
534 *,
535 now: datetime,
536 repos: tuple[str, ...],
537 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
538 policy: AutonomyPolicy = _DEFAULT_POLICY,
539 scan_interval_seconds: int,
540 review_interval_seconds: int,
541 github: tuple[tuple[GithubPr, ...], str | None],
542 temporal: tuple[
543 tuple[
544 tuple[ScanExecution, ...],
545 tuple[BumpExecution, ...],
546 tuple[ReviewExecution, ...],
547 tuple[PrReviewExecution, ...],
548 ],
549 str | None,
550 ],
551 telemetry: tuple[RunTelemetry, str | None],
552) -> DashboardModel:
553 """Build the whole view from the readers' ``(data, error)`` outputs."""
554 prs, github_error = github
555 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
556 run_telemetry, clickhouse_error = telemetry
557 
558 rows = _bump_rows(now, prs, bumps)
559 class_gates = _class_gates(now, rows, repos, loops, policy)
560 review_loops = _review_loops(repos, reviews)
561 review_rows = _review_rows(pr_reviews, repos)
562 return DashboardModel(
563 generated_at=now,
564 repos_configured=repos,
565 scan_interval_seconds=scan_interval_seconds,
566 sources=_sources(
567 github_error,
568 len(prs),
569 temporal_error,
570 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
571 run_telemetry,
572 clickhouse_error,
573 ),
574 scan_loops=_scan_loops(repos, loops, scans),
575 track_record=_track_record(rows),
576 class_gates=class_gates,
577 verification=_verification(rows),
578 judgment=_judgment(rows),
579 gate=_gate(rows, class_gates, policy),
580 bumps=rows,
581 failures=_failures(bumps),
582 review_interval_seconds=review_interval_seconds,
583 review_loops=review_loops,
584 review_record=_review_record(review_loops, review_rows),
585 reviews=review_rows,
586 telemetry=run_telemetry,
587 )

What the page shows — model.py + render.py

The view model gains a frozen ClassGate, and BumpRow gains would_auto_merge / held_reason (plus a loop label so the two loops' records never mix). Every figure is computed in the read-model, so the renderer stays a dumb projection.

ClassGate — the per-class standing, fully derived.

src/froot/dashboard/model.py · 357 lines
src/froot/dashboard/model.py357 lines · Python
⋯ 101 lines hidden (lines 1–101)
1"""The dashboard's view model — pure, frozen, fully derived.
2 
3These types are the shape the renderer projects to HTML. They carry *already
4computed* numbers (the aggregates live in :mod:`~froot.dashboard.read_model`),
5so the renderer is a dumb projection and every figure on the page is
6unit-tested apart from any I/O. Nothing here is persisted: a
7:class:`DashboardModel` is built fresh per request and discarded after the
8response (derive, never store).
9"""
10 
11from __future__ import annotations
12 
13from datetime import datetime
14 
15from froot.domain.base import Frozen
16 
17 
18class SourceHealth(Frozen):
19 """Whether one external truth answered this request.
20 
21 Attributes:
22 name: The source label (``github`` / ``temporal`` / ``clickhouse``).
23 ok: True when the source returned data; False when it errored or is off.
24 detail: A short human note — the row count, ``off``, or the error.
25 """
26 
27 name: str
28 ok: bool
29 detail: str
30 
31 
32class ScanLoop(Frozen):
33 """The liveness of one repo's durable scan schedule (the signal stage).
34 
35 Attributes:
36 repo: The ``owner/name`` slug this loop watches.
37 loop: Which maintenance loop this row is for (``dependency-patch`` /
38 ``security-patch``), so the two loops' scans show distinctly.
39 status: The current scan workflow status (``running`` /
40 ``continued_as_new`` / ``terminated`` / ``none`` / ...), lowercased.
41 live: True when the loop is actively self-scheduling (running / CAN).
42 last_tick: When the current scan execution started (≈ the last tick),
43 or ``None`` if no scan workflow exists for the repo.
44 """
45 
46 repo: str
47 loop: str = "dependency-patch"
48 status: str
49 live: bool
50 last_tick: datetime | None
51 
52 
53class BumpRow(Frozen):
54 """One proposed dependency bump, joined across GitHub and Temporal.
55 
56 GitHub is authoritative for the outcome (state / timestamps / PR); Temporal
57 enriches with the model verdict and the CI reading while it is still in the
58 7-day window, with the PR body as the durable fallback for the verdict.
59 
60 Attributes:
61 repo: The ``owner/name`` slug.
62 loop: Which loop proposed it (``dependency-patch`` /
63 ``security-patch``), from the PR's loop label — so the two loops'
64 records never mix.
65 package: The bumped dependency.
66 from_version: The version bumped from, if known.
67 to_version: The version bumped to.
68 state: The GitHub PR state — ``open`` / ``merged`` / ``closed``.
69 verdict: The changelog verdict (``clean`` / ``risky`` / ``unknown``), or
70 ``None`` when neither Temporal nor the PR body yields one.
71 ci: The terminal CI reading (``passed`` / ``failed`` / ``absent`` /
72 ``timed_out``), or ``None`` when only durable GitHub data remains.
73 pr_number: The PR number, if a PR exists.
74 pr_url: The PR URL, if a PR exists.
75 opened_at: When the PR was opened.
76 merged_at: When the PR was merged, if it was.
77 ttm_minutes: Time-to-merge in minutes (merged - opened), if merged.
78 age_hours: Age in hours for a still-open PR (now - opened), or ``None``.
79 would_auto_merge: For an open PR, whether it would auto-merge under the
80 advisory earned-autonomy grant (the shadow gate; nothing acts).
81 held_reason: Why an open PR would *not* auto-merge, the first blocker.
82 """
83 
84 repo: str
85 loop: str = "dependency-patch"
86 package: str
87 from_version: str | None
88 to_version: str
89 state: str
90 verdict: str | None
91 ci: str | None
92 pr_number: int | None
93 pr_url: str | None
94 opened_at: datetime | None
95 merged_at: datetime | None
96 ttm_minutes: float | None
97 age_hours: float | None
98 would_auto_merge: bool = False
99 held_reason: str | None = None
100 
101 
102class ClassGate(Frozen):
103 """The earned-autonomy standing of one (repo, loop) class — advisory only.
104 
105 The MHE economics of approval (§3.6) made legible: a class earns its gate
106 move with a high enough approval rate over enough recently-decided PRs,
107 and the budget framing shows what moving the gate would reclaim.
108 
109 Attributes:
110 repo: The ``owner/name`` slug.
111 loop: The loop this class is for.
112 decided: PRs decided (merged or closed) in the recent window.
113 merged: How many of those were merged.
114 merge_rate: ``merged / decided``, or ``None`` if none decided.
115 earned: Whether the class has earned its gate move under the policy.
116 blocker: Why it has not, if it has not (else ``None``).
117 approvals_per_week: The steward approvals this class costs now
118 (merges per week over the window) — the current budget draw.
119 reclaim_per_week: How many of those a gate move would auto-merge
120 (the clean-and-green ones), i.e. the budget reclaimed — ``0`` until
121 the class is ``earned``, since an un-earned class reclaims nothing.
122 window_days: The look-back window the figures cover.
123 """
124 
125 repo: str
126 loop: str
127 decided: int
128 merged: int
129 merge_rate: float | None
130 earned: bool
131 blocker: str | None
132 approvals_per_week: float
133 reclaim_per_week: float
134 window_days: int
⋯ 223 lines hidden (lines 135–357)
135 
136 
137class Failure(Frozen):
138 """A bump loop that did not close — the honest friction signal.
139 
140 Attributes:
141 workflow_id: The Temporal workflow id (encodes repo/package/target).
142 kind: ``terminated`` or ``failed``.
143 reason: The human-readable termination/failure reason, if recovered.
144 when: When the workflow closed, if known.
145 """
146 
147 workflow_id: str
148 kind: str
149 reason: str | None
150 when: datetime | None
151 
152 
153class ActivityStat(Frozen):
154 """Latency of one activity stage, from traces (run-telemetry enrichment).
155 
156 Attributes:
157 name: The activity name (``scan_candidates``, ``open_pull_request``).
158 count: How many executions in the window.
159 avg_ms: Mean duration in milliseconds.
160 max_ms: Max duration in milliseconds.
161 """
162 
163 name: str
164 count: int
165 avg_ms: float
166 max_ms: float
167 
168 
169class RunTelemetry(Frozen):
170 """Trace-derived run telemetry from ClickHouse, or an unavailable marker.
171 
172 Attributes:
173 available: True when ClickHouse answered with froot traces.
174 total_spans: Total froot spans in the window.
175 error_spans: Spans that ended in an error.
176 last_activity: The most recent froot span timestamp.
177 window_days: The look-back window the figures cover.
178 activities: Per-stage latency rows.
179 """
180 
181 available: bool
182 total_spans: int
183 error_spans: int
184 last_activity: datetime | None
185 window_days: int
186 activities: tuple[ActivityStat, ...]
187 
188 
189class TrackRecord(Frozen):
190 """The reputation headline, derived from GitHub PR outcomes.
191 
192 Attributes:
193 opened: Total bumps froot has proposed.
194 merged: How many a human merged.
195 closed_unmerged: How many were closed without merging.
196 open_now: How many are still awaiting a decision.
197 merge_rate: ``merged / (merged + closed_unmerged)``, or ``None`` if none
198 have been decided yet.
199 median_ttm_minutes: Median time-to-merge across merged PRs, or ``None``.
200 """
201 
202 opened: int
203 merged: int
204 closed_unmerged: int
205 open_now: int
206 merge_rate: float | None
207 median_ttm_minutes: float | None
208 
209 
210class Verification(Frozen):
211 """The CI-oracle breakdown — kept honest about whether an oracle existed.
212 
213 Attributes:
214 passed: Bumps whose CI went green.
215 failed: Bumps whose CI went red.
216 absent: Bumps where no CI check existed (no oracle).
217 timed_out: Bumps where froot stopped waiting on CI.
218 unknown: Bumps with no recoverable CI reading (aged out of Temporal).
219 oracle_existed: Bumps where a real oracle reported (passed + failed).
220 with_reading: Bumps with any CI reading at all (excludes ``unknown``).
221 """
222 
223 passed: int
224 failed: int
225 absent: int
226 timed_out: int
227 unknown: int
228 oracle_existed: int
229 with_reading: int
230 
231 
232class Judgment(Frozen):
233 """The model's changelog-verdict mix and its calibration against CI.
234 
235 Attributes:
236 clean: Verdicts of ``clean``.
237 risky: Verdicts of ``risky``.
238 unknown: Verdicts of ``unknown``.
239 none: Bumps with no recoverable verdict.
240 clean_but_failed: ``clean`` verdicts whose CI failed (mis-judged).
241 flagged_but_passed: ``risky``/``unknown`` verdicts whose CI passed.
242 """
243 
244 clean: int
245 risky: int
246 unknown: int
247 none: int
248 clean_but_failed: int
249 flagged_but_passed: int
250 
251 
252class ReviewLoop(Frozen):
253 """Liveness of one repo's determinism-review loop (the transitive ring).
254 
255 Attributes:
256 repo: The ``owner/name`` slug this loop reviews.
257 status: The review workflow status (``running`` /
258 ``continued_as_new`` / ``terminated`` / ...), lowercased.
259 live: True when the loop is actively self-scheduling.
260 last_tick: When the current review execution started (≈ the last tick),
261 or ``None`` if no review workflow exists for the repo.
262 """
263 
264 repo: str
265 status: str
266 live: bool
267 last_tick: datetime | None
268 
269 
270class ReviewRow(Frozen):
271 """One per-PR determinism review, from its ``PrReviewWorkflow`` result.
272 
273 Attributes:
274 repo: The ``owner/name`` slug the PR belongs to.
275 pr_number: The reviewed PR number, if known.
276 pr_url: The PR's web URL, if it can be formed.
277 head_sha: The head commit the review ran against.
278 findings: How many transitive hazards the review surfaced.
279 rules: The distinct banned calls flagged (``datetime.datetime.now``…).
280 comment_url: The advisory comment's URL, if one was posted.
281 status: The review workflow status (``completed`` / ``running`` / ...).
282 reviewed_at: When the review closed, or started if still running.
283 """
284 
285 repo: str
286 pr_number: int | None
287 pr_url: str | None
288 head_sha: str | None
289 findings: int
290 rules: tuple[str, ...]
291 comment_url: str | None
292 status: str
293 reviewed_at: datetime | None
294 
295 
296class ReviewRecord(Frozen):
297 """The determinism reviewer's headline, derived from completed reviews.
298 
299 The hazard-resolved rate (was a flagged hazard gone on a later commit?) is
300 a later addition — it needs accumulated cross-commit history, so it is not
301 here yet.
302 
303 Attributes:
304 reviewed: Completed per-PR reviews in the recent window.
305 flagged: Reviews that surfaced at least one hazard.
306 clean: Reviews that surfaced none.
307 hazards: Total hazards surfaced across all reviews.
308 repos_covered: Distinct repos with a live review loop.
309 """
310 
311 reviewed: int
312 flagged: int
313 clean: int
314 hazards: int
315 repos_covered: int
316 
317 
318class DashboardModel(Frozen):
319 """The whole 10,000ft view, fully derived and ready to render.
320 
321 Attributes:
322 generated_at: When this view was assembled (UTC).
323 repos_configured: The repos froot is pointed at (``FROOT_REPOS``).
324 scan_interval_seconds: The configured gap between scan ticks.
325 sources: Per-source health for this request.
326 scan_loops: Liveness of each repo's scan schedule.
327 track_record: The reputation headline.
328 class_gates: The earned-autonomy standing per (repo, loop) — advisory.
329 verification: The CI-oracle breakdown.
330 judgment: The model-verdict mix and calibration.
331 gate: Open PRs awaiting a human, the freshest last.
332 bumps: Every proposed bump, newest first (the detail behind the stats).
333 failures: Bump loops that did not close.
334 review_interval_seconds: The configured gap between review poll ticks.
335 review_loops: Liveness of each Temporal repo's determinism-review loop.
336 review_record: The determinism reviewer's headline.
337 reviews: Every per-PR determinism review, newest first.
338 telemetry: Trace-derived run telemetry (best-effort).
339 """
340 
341 generated_at: datetime
342 repos_configured: tuple[str, ...]
343 scan_interval_seconds: int
344 sources: tuple[SourceHealth, ...]
345 scan_loops: tuple[ScanLoop, ...]
346 track_record: TrackRecord
347 class_gates: tuple[ClassGate, ...]
348 verification: Verification
349 judgment: Judgment
350 gate: tuple[BumpRow, ...]
351 bumps: tuple[BumpRow, ...]
352 failures: tuple[Failure, ...]
353 review_interval_seconds: int
354 review_loops: tuple[ReviewLoop, ...]
355 review_record: ReviewRecord
356 reviews: tuple[ReviewRow, ...]
357 telemetry: RunTelemetry

The renderer adds an "Earned autonomy · the shadow gate" panel and a per-PR badge in the approval queue. The badge reads would auto-merge or held · <reason>; both the panel note and the gate note are scrupulous that nothing acts and that a merge-rate record is not a confirmed-good (or security-reviewed) outcome.

The panel: a class row table plus a folded MHE framing note.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 274 lines hidden (lines 1–274)
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 ClassGate,
20 DashboardModel,
21 ReviewLoop,
22 ReviewRow,
23 RunTelemetry,
24 ScanLoop,
25 )
26 
27_CSS = """
28:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
29--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
30@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
31--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
32--accent:#58a6ff;--card:#141414}}
33*{box-sizing:border-box}
34body{margin:0;background:var(--bg);color:var(--fg);
35font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
36main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
37h1{font-size:22px;margin:0;letter-spacing:-.01em}
38.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
39.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
40.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
41section{margin:30px 0 0}
42h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
43color:var(--mut);margin:0 0 12px;font-weight:600;
44border-bottom:1px solid var(--line);padding-bottom:6px}
45.stats{display:flex;flex-wrap:wrap;gap:26px}
46.stat .n{font-size:26px;font-weight:600;line-height:1.1}
47.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
48.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
49margin-right:7px}
50.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
51.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
52table{width:100%;border-collapse:collapse;font-size:13px}
53th,td{text-align:left;padding:6px 12px 6px 0;
54border-bottom:1px solid var(--line);vertical-align:top}
55th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
56letter-spacing:.04em}
57.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
58a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
59.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
60.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
61.bad{color:var(--bad)}
62.note{color:var(--mut);font-size:12px;margin:10px 0 0}
63footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
64color:var(--mut);font-size:12px;line-height:1.7}
65footer b{color:var(--fg);font-weight:600}
66/* loop groups — collapsible (collapsed by default), two-level hierarchy */
67details.loop{margin:24px 0 0}
68summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
69cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
70border-bottom:2px solid var(--fg)}
71summary.loophead::-webkit-details-marker{display:none}
72summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
73font-size:11px;position:relative;top:-1px}
74details.loop[open]>summary.loophead::before{content:"\\25BE"}
75summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
76color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
77summary.loophead .sub{color:var(--mut);font-size:12px}
78summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
79align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
80summary.loophead .glance b{color:var(--fg);font-weight:600}
81/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
82details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
83details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
84font-weight:600;text-transform:uppercase;letter-spacing:.06em}
85/* subsections sit tighter inside a loop; the loop rule carries the weight */
86details.loop section{margin:18px 0 0}
87details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
88/* foldable framing — the MHE notes, one click away, so the page scans clean */
89details.why{margin:9px 0 0}
90details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
91list-style:none}
92details.why>summary::-webkit-details-marker{display:none}
93details.why>summary::before{content:"\\002b why";font-weight:600}
94details.why[open]>summary::before{content:"\\2212 why"}
95details.why .note{margin:6px 0 0}
96"""
97 
98_CI_CLASS = {
99 "passed": "ok",
100 "failed": "bad",
101 "absent": "mut",
102 "timed_out": "warn",
104_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
105 
106 
107def _aware(when: datetime) -> datetime:
108 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
109 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
110 
111 
112def _ago(when: datetime | None, now: datetime) -> str:
113 """A compact 'time since' label (``6h ago``)."""
114 if when is None:
115 return ""
116 secs = (now - _aware(when)).total_seconds()
117 if secs < 90:
118 return "just now"
119 if secs < 5400:
120 return f"{int(secs // 60)}m ago"
121 if secs < 129600:
122 return f"{int(secs // 3600)}h ago"
123 return f"{int(secs // 86400)}d ago"
124 
125 
126def _until(when: datetime | None, now: datetime) -> str:
127 """A compact 'time until' label (``in 18h`` / ``due now``)."""
128 if when is None:
129 return ""
130 secs = (_aware(when) - now).total_seconds()
131 if secs <= 0:
132 return "due now"
133 if secs < 5400:
134 return f"in {int(secs // 60)}m"
135 if secs < 129600:
136 return f"in {int(secs // 3600)}h"
137 return f"in {int(secs // 86400)}d"
138 
139 
140def _dot(kind: str) -> str:
141 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
142 return f'<span class="dot {kind}"></span>'
143 
144 
145def _tag(value: str | None, classes: dict[str, str]) -> str:
146 """A small coloured label for a verdict/CI value, or an em-dash."""
147 if value is None:
148 return '<span class="mut">—</span>'
149 cls = classes.get(value, "mut")
150 return f'<span class="{cls}">{escape(value)}</span>'
151 
152 
153def _stat(n: object, label: str) -> str:
154 return (
155 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
156 f'<div class="l">{escape(label)}</div></div>'
157 )
158 
159 
160def _why(note_html: str) -> str:
161 """Fold a framing note behind a native ``<details>`` so the page scans."""
162 return f'<details class="why"><summary></summary>{note_html}</details>'
163 
164 
165def _glance(n: object, label: str) -> str:
166 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
167 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
168 
169 
170def _header(model: DashboardModel) -> str:
171 now = model.generated_at
172 repos = ", ".join(model.repos_configured) or "none configured"
173 dots = "".join(
174 f"<span>{_dot('ok' if s.ok else 'bad')}"
175 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
176 for s in model.sources
177 )
178 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
179 return (
180 "<header>"
181 "<h1>froot</h1>"
182 '<p class="tag">durable maintenance loops &middot; '
183 "reputation read-model</p>"
184 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
185 f" &middot; generated {escape(stamp)} &middot; "
186 "derived live, stored nowhere</p>"
187 f'<div class="sources">{dots}</div>'
188 "</header>"
189 )
190 
191 
192def _heartbeat(model: DashboardModel) -> str:
193 now = model.generated_at
194 interval = model.scan_interval_seconds
195 
196 def line(loop: ScanLoop) -> str:
197 if loop.live:
198 dot, tail = "ok", ""
199 if loop.last_tick is not None:
200 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
201 last = _ago(loop.last_tick, now)
202 tail = (
203 f' <span class="mut">&middot; last {last}'
204 f" &middot; next {_until(nxt, now)}</span>"
205 )
206 else:
207 dot = "bad" if loop.status in ("terminated", "none") else "warn"
208 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
209 return (
210 f'<div class="row">{_dot(dot)}'
211 f'<span class="mono">{escape(loop.repo)}</span>'
212 f' <span class="mut">{escape(loop.loop)}</span>{tail}</div>'
213 )
214 
215 if not model.scan_loops:
216 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
217 else:
218 body = "".join(line(loop) for loop in model.scan_loops)
219 return f"<section><h2>Is it alive?</h2>{body}</section>"
220 
221 
222def _track_record(model: DashboardModel) -> str:
223 t = model.track_record
224 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
225 ttm = (
226 ""
227 if t.median_ttm_minutes is None
228 else f"{t.median_ttm_minutes:.0f} min"
229 )
230 stats = "".join(
231 (
232 _stat(t.opened, "proposed"),
233 _stat(t.merged, "merged"),
234 _stat(t.open_now, "awaiting"),
235 _stat(t.closed_unmerged, "closed"),
236 _stat(rate, "merge rate"),
237 _stat(ttm, "median time-to-merge"),
238 )
239 )
240 note = (
241 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
242 "npm patch bumps by construction. It counts a human merge, not a "
243 "confirmed good outcome: revert tracking is a later loop, so merge is "
244 "not yet proof of success.</p>"
245 )
246 return (
247 "<section><h2>Track record &middot; the reputation</h2>"
248 f'<div class="stats">{stats}</div>{_why(note)}</section>'
249 )
250 
251 
252def _rate_cell(rate: float | None) -> str:
253 """A class's approval rate as a percent, or an em-dash if none decided."""
254 if rate is None:
255 return '<span class="mut">—</span>'
256 return f"{rate * 100:.0f}%"
257 
258 
259def _earned_cell(g: ClassGate) -> str:
260 """A class's gate standing: a green 'earned', else the muted blocker."""
261 if g.earned:
262 return f'{_dot("ok")}<span class="ok">earned</span>'
263 blocker = escape(g.blocker or "not earned")
264 return f'{_dot("mut")}<span class="mut">{blocker}</span>'
265 
266 
267def _budget_cell(g: ClassGate) -> str:
268 """The gate in steward-time: approvals/week now &rarr; reclaimable/week."""
269 return (
270 f'<span class="mono">{g.approvals_per_week:.1f}</span> appr'
271 f' <span class="mut">&rarr; {g.reclaim_per_week:.1f} reclaimable</span>'
272 )
273 
274 
275def _class_gates(model: DashboardModel) -> str:
276 """The earned-autonomy panel — the shadow gate's per-class standing.
277 
278 Read-only: it renders whether each (repo, loop) class *would* have earned a
279 gate move, and the steward-budget that move would reclaim. Nothing acts.
280 """
281 gates = model.class_gates
282 if not gates:
283 body = (
284 '<p class="note">No classes yet &mdash; a (repo, loop) earns a '
285 "gate move from its own track record.</p>"
286 )
287 else:
288 rows = "".join(
289 "<tr>"
290 f'<td class="mono">{escape(g.repo)}</td>'
291 f'<td class="mut">{escape(g.loop)}</td>'
292 f"<td>{g.decided} in {g.window_days}d</td>"
293 f"<td>{_rate_cell(g.merge_rate)}</td>"
294 f"<td>{_earned_cell(g)}</td>"
295 f"<td>{_budget_cell(g)}</td>"
296 "</tr>"
297 for g in gates
298 )
299 body = (
300 "<table><thead><tr><th>repo</th><th>loop</th><th>decided</th>"
301 "<th>rate</th><th>gate</th><th>budget / week</th></tr></thead>"
302 f"<tbody>{rows}</tbody></table>"
303 )
304 note = (
305 '<p class="note">Each <b>class</b> is one loop on one repo &mdash; a '
306 "distinct trust unit (a security patch is not a version bump, and they "
307 "never share a record). A class earns its gate from a high enough "
308 "<b>approval rate</b> over enough recently-<b>decided</b> PRs; the "
309 "window keeps trust recent, not lifetime. <b>Budget</b> reads the "
310 "gate in steward-time &mdash; approvals/week is what the class costs "
311 "a human now, reclaimable is the clean-and-green slice a gate move "
312 "would hand back. <b>Advisory only</b>: auto-merge is allowlist-gated "
313 "and off, and the approval rate counts a human <i>merge</i>, not a "
314 "confirmed-good outcome &mdash; with no revert tracking yet, a high "
315 "rate can still mask rubber-stamping (&sect;3.7). Rollback tracking "
316 "and sampled review come before anything acts.</p>"
317 )
318 return (
319 "<section><h2>Earned autonomy &middot; the shadow gate</h2>"
⋯ 444 lines hidden (lines 320–763)
320 f"{body}{_why(note)}</section>"
321 )
322 
323 
324def _verification(model: DashboardModel) -> str:
325 v = model.verification
326 stats = "".join(
327 (
328 _stat(v.passed, "CI passed"),
329 _stat(v.failed, "CI failed"),
330 _stat(v.absent, "no checks"),
331 _stat(v.timed_out, "timed out"),
332 _stat(v.unknown, "unknown"),
333 )
334 )
335 if v.with_reading == 0:
336 note = '<p class="note">No CI readings yet.</p>'
337 else:
338 note = _why(
339 f'<p class="note">A real oracle reported on '
340 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
341 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
342 "absent &mdash; not a pass; never conflated.</span>"
343 "</p>"
344 )
345 return (
346 "<section><h2>Verification &middot; CI is the oracle</h2>"
347 f'<div class="stats">{stats}</div>{note}</section>'
348 )
349 
350 
351def _judgment(model: DashboardModel) -> str:
352 j = model.judgment
353 stats = "".join(
354 (
355 _stat(j.clean, "clean"),
356 _stat(j.risky, "risky"),
357 _stat(j.unknown, "unknown"),
358 _stat(j.none, "no verdict"),
359 )
360 )
361 if j.clean_but_failed or j.flagged_but_passed:
362 note = (
363 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
364 "&lsquo;clean&rsquo; bumps whose CI failed, "
365 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
366 )
367 else:
368 note = (
369 '<p class="note">The model&rsquo;s only job is the changelog '
370 "verdict; the spine proposes the bump either way.</p>"
371 )
372 return (
373 "<section><h2>Model judgment &middot; the one model call</h2>"
374 f'<div class="stats">{stats}</div>{_why(note)}</section>'
375 )
376 
377 
378def _shadow_badge(row: BumpRow) -> str:
379 """Whether this open PR *would* auto-merge under its class's grant.
380 
381 Advisory: a green 'would auto-merge' means every condition is met and the
382 human approval is the only thing still in the way; otherwise the first
383 blocker, muted, so the held reason is the thing to fix.
384 """
385 if row.would_auto_merge:
386 return '<span class="ok">would auto-merge</span>'
387 reason = row.held_reason or "held"
388 return f'<span class="mut">held &middot; {escape(reason)}</span>'
389 
390 
391def _gate(model: DashboardModel) -> str:
392 now = model.generated_at
393 if not model.gate:
394 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
395 else:
396 rows = "".join(
397 "<tr>"
398 f'<td class="mono">{escape(row.package)}</td>'
399 f"<td>{escape(_ago(row.opened_at, now))}</td>"
400 f"<td>{_shadow_badge(row)}</td>"
401 f"<td>{_pr_link(row)}</td>"
402 "</tr>"
403 for row in model.gate
404 )
405 note = _why(
406 '<p class="note">The <b>shadow gate</b> column is advisory &mdash; '
407 "froot acts on nothing. A green &lsquo;would auto-merge&rsquo; "
408 "means this PR met its class&rsquo;s grant and the human approval "
409 "is the only remaining step; it rests on the single CI oracle and "
410 "a merge-rate record, which (esp. for a security patch) is not the "
411 "same as a confirmed-good or security-reviewed outcome. You still "
412 "own every merge.</p>"
413 )
414 body = (
415 "<table><thead><tr><th>package</th><th>waiting</th>"
416 "<th>shadow gate</th><th>pr</th>"
417 f"</tr></thead><tbody>{rows}</tbody></table>{note}"
418 )
419 return (
420 "<section><h2>Approval gate &middot; what a human owns</h2>"
421 f"{body}</section>"
422 )
423 
424 
425def _bumps(model: DashboardModel) -> str:
426 now = model.generated_at
427 if not model.bumps:
428 body = '<p class="note">No bumps proposed yet.</p>'
429 else:
430 rows = "".join(
431 "<tr>"
432 f'<td class="mono">{escape(row.package)}</td>'
433 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
434 f"{escape(row.to_version)}</td>"
435 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
436 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
437 f"<td>{_state_tag(row.state)}</td>"
438 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
439 f"<td>{_pr_link(row)}</td>"
440 "</tr>"
441 for row in model.bumps
442 )
443 body = (
444 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
445 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
446 f"<tbody>{rows}</tbody></table>"
447 )
448 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
449 
450 
451def _failures(model: DashboardModel) -> str:
452 if not model.failures:
453 return ""
454 now = model.generated_at
455 rows = "".join(
456 "<tr>"
457 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
458 f"<td>{_state_tag(f.kind)}</td>"
459 f'<td class="mut">{escape(f.reason or "")}</td>'
460 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
461 "</tr>"
462 for f in model.failures
463 )
464 return (
465 "<section><h2>Failures &middot; where the loop did not close</h2>"
466 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
467 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
468 )
469 
470 
471def _telem_glance(model: DashboardModel) -> str:
472 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
473 t = model.telemetry
474 if not t.available:
475 return '<span class="mut">unavailable</span>'
476 return _glance(t.total_spans, f"spans / {t.window_days}d")
477 
478 
479def _telemetry(model: DashboardModel) -> str:
480 """The telemetry body — header lives in the shared loop group above it."""
481 t: RunTelemetry = model.telemetry
482 if not t.available:
483 return (
484 "<section>"
485 '<p class="note">Unavailable (not configured or unreachable). '
486 "GitHub + Temporal carry the dashboard regardless.</p></section>"
487 )
488 now = model.generated_at
489 if t.activities:
490 rows = "".join(
491 "<tr>"
492 f'<td class="mono">{escape(a.name)}</td>'
493 f"<td>{a.count}</td>"
494 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
495 f'<td class="mut">{a.max_ms:.0f} ms</td>'
496 "</tr>"
497 for a in t.activities
498 )
499 table = (
500 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
501 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
502 )
503 else:
504 table = '<p class="note">No froot spans in the window.</p>'
505 summary = (
506 f'<p class="note">{t.total_spans} spans &middot; '
507 f"{t.error_spans} errored &middot; last activity "
508 f"{escape(_ago(t.last_activity, now))} &middot; "
509 f"{t.window_days}-day window.</p>"
510 )
511 return f"<section>{summary}{table}</section>"
512 
513 
514def _review_heartbeat(model: DashboardModel) -> str:
515 now = model.generated_at
516 interval = model.review_interval_seconds
517 
518 def line(loop: ReviewLoop) -> str:
519 if loop.live:
520 dot, tail = "ok", ""
521 if loop.last_tick is not None:
522 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
523 last = _ago(loop.last_tick, now)
524 tail = (
525 f' <span class="mut">&middot; last {last}'
526 f" &middot; next {_until(nxt, now)}</span>"
527 )
528 else:
529 dot = "bad" if loop.status in ("terminated", "none") else "warn"
530 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
531 return (
532 f'<div class="row">{_dot(dot)}'
533 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
534 )
535 
536 if not model.review_loops:
537 body = (
538 '<p class="note">No determinism-review loops running '
539 "(the transitive ring watches the @workflow.defn repos).</p>"
540 )
541 else:
542 body = "".join(line(loop) for loop in model.review_loops)
543 return f"<section><h2>Is it alive?</h2>{body}</section>"
544 
545 
546def _review_record(model: DashboardModel) -> str:
547 r = model.review_record
548 stats = "".join(
549 (
550 _stat(r.reviewed, "reviewed"),
551 _stat(r.flagged, "flagged"),
552 _stat(r.clean, "clean"),
553 _stat(r.hazards, "hazards"),
554 _stat(r.repos_covered, "repos covered"),
555 )
556 )
557 note = (
558 '<p class="note">The transitive ring: it chases first-party helper '
559 "calls out of each workflow to catch a hazard the lexical CI kernel "
560 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
561 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
562 "a later commit?) is a later loop; it needs accumulated history.</p>"
563 )
564 return (
565 "<section><h2>Track record &middot; the reputation</h2>"
566 f'<div class="stats">{stats}</div>{_why(note)}</section>'
567 )
568 
569 
570def _reviews(model: DashboardModel) -> str:
571 now = model.generated_at
572 if not model.reviews:
573 body = '<p class="note">No PRs reviewed yet.</p>'
574 else:
575 rows = "".join(
576 "<tr>"
577 f'<td class="mono">{escape(row.repo)}</td>'
578 f"<td>{_review_pr_link(row)}</td>"
579 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
580 "</td>"
581 f"<td>{_findings_cell(row)}</td>"
582 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
583 "</tr>"
584 for row in model.reviews
585 )
586 body = (
587 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
588 "<th>findings</th><th>reviewed</th></tr></thead>"
589 f"<tbody>{rows}</tbody></table>"
590 )
591 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
592 
593 
594def _footer() -> str:
595 return (
596 "<footer>"
597 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
598 "<b>write authority</b> only: it opens PRs, a human approves every "
599 "merge (commit authority = none). Trust, when any is granted, is "
600 "earned, narrow to a single loop on a single repo (a version bump is "
601 "not a security patch; they never share a record), conditional on its "
602 'environment (judge <span class="mono">gemma4:e4b</span>, '
603 "lockfile-only regen), revocable, and time-expiring. Today it records "
604 "the track "
605 "record and renders a <b>shadow gate</b> &mdash; a dry run of the "
606 "auto-merge decision that still acts on nothing.<br>"
607 "Everything above is derived on this request from GitHub (outcomes) + "
608 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
609 "reload to recompute."
610 "</footer>"
611 )
612 
613 
614def _pr_link(row: BumpRow) -> str:
615 if row.pr_url is None or row.pr_number is None:
616 return '<span class="mut">—</span>'
617 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
618 
619 
620def _review_pr_link(row: ReviewRow) -> str:
621 if row.pr_url is None or row.pr_number is None:
622 return '<span class="mut">—</span>'
623 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
624 
625 
626def _findings_cell(row: ReviewRow) -> str:
627 """A review's findings: 'clean', or the hazard count + rules + comment."""
628 if row.findings == 0:
629 return '<span class="ok">clean</span>'
630 rules = (
631 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
632 if row.rules
633 else ""
634 )
635 comment = (
636 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
637 if row.comment_url
638 else ""
639 )
640 noun = "hazard" if row.findings == 1 else "hazards"
641 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
642 
643 
644def _state_tag(state: str) -> str:
645 cls = {
646 "merged": "ok",
647 "open": "warn",
648 "closed": "mut",
649 "terminated": "bad",
650 "failed": "bad",
651 "timed_out": "bad",
652 "canceled": "warn",
653 }.get(state, "mut")
654 return f'<span class="{cls}">{escape(state)}</span>'
655 
656 
657def _short_id(workflow_id: str) -> str:
658 """Drop the ``froot-bump-`` prefix for a readable failures row."""
659 return workflow_id.removeprefix("froot-bump-")
660 
661 
662def _dep_glance(model: DashboardModel) -> str:
663 """The dependency-patch loop's at-a-glance, shown in its header."""
664 t = model.track_record
665 live = sum(1 for loop in model.scan_loops if loop.live)
666 total = len(model.scan_loops)
667 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
668 items = [
669 _glance(f"{live}/{total}", "live"),
670 _glance(t.opened, "proposed"),
671 _glance(rate, "merged"),
672 ]
673 if model.class_gates:
674 earned = sum(1 for g in model.class_gates if g.earned)
675 items.append(_glance(f"{earned}/{len(model.class_gates)}", "earned"))
676 if t.open_now:
677 items.append(_glance(t.open_now, "awaiting you"))
678 return "".join(items)
679 
680 
681def _det_glance(model: DashboardModel) -> str:
682 """The determinism-review loop's at-a-glance, shown in its header."""
683 r = model.review_record
684 return "".join(
685 (
686 _glance(r.repos_covered, "repos"),
687 _glance(r.reviewed, "reviewed"),
688 _glance(r.hazards, "hazards"),
689 )
690 )
691 
692 
693def _loop(
694 title: str,
695 sub: str,
696 glance: str,
697 sections: tuple[str, ...],
698 *,
699 shared: bool = False,
700) -> str:
701 """A collapsible group: a prominent header + at-a-glance, collapsed.
702 
703 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
704 still shows the headline; expanding reveals the subsections. ``shared`` is a
705 non-loop infra group (telemetry) with a lighter, distinct header.
706 """
707 cls = "loop shared" if shared else "loop"
708 head = (
709 '<summary class="loophead">'
710 f"<h2>{escape(title)}</h2>"
711 f'<span class="sub">{sub}</span>'
712 f'<span class="glance">{glance}</span>'
713 "</summary>"
714 )
715 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
716 
717 
718def page(model: DashboardModel) -> str:
719 """Render the whole dashboard as one self-contained HTML document."""
720 parts = (
721 _header(model),
722 _loop(
723 "Dependency-patch",
724 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
725 _dep_glance(model),
726 (
727 _heartbeat(model),
728 _track_record(model),
729 _class_gates(model),
730 _verification(model),
731 _judgment(model),
732 _gate(model),
733 _bumps(model),
734 _failures(model),
735 ),
736 ),
737 _loop(
738 "Determinism review",
739 "the transitive ring &middot; advisory",
740 _det_glance(model),
741 (
742 _review_heartbeat(model),
743 _review_record(model),
744 _reviews(model),
745 ),
746 ),
747 _loop(
748 "Run telemetry",
749 "ClickHouse &middot; trace-derived, best-effort",
750 _telem_glance(model),
751 (_telemetry(model),),
752 shared=True,
753 ),
754 _footer(),
755 )
756 return (
757 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
758 '<meta name="viewport" content="width=device-width,initial-scale=1">'
759 "<title>froot &middot; read-model</title>"
760 f"<style>{_CSS}</style></head><body><main>"
761 + "".join(parts)
762 + "</main></body></html>"
763 )

The shadow-gate badge — advisory; the green case means the human approval is the only remaining step.

src/froot/dashboard/render.py · 763 lines
src/froot/dashboard/render.py763 lines · Python
⋯ 377 lines hidden (lines 1–377)
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 ClassGate,
20 DashboardModel,
21 ReviewLoop,
22 ReviewRow,
23 RunTelemetry,
24 ScanLoop,
25 )
26 
27_CSS = """
28:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
29--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
30@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
31--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
32--accent:#58a6ff;--card:#141414}}
33*{box-sizing:border-box}
34body{margin:0;background:var(--bg);color:var(--fg);
35font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
36main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
37h1{font-size:22px;margin:0;letter-spacing:-.01em}
38.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
39.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
40.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
41section{margin:30px 0 0}
42h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
43color:var(--mut);margin:0 0 12px;font-weight:600;
44border-bottom:1px solid var(--line);padding-bottom:6px}
45.stats{display:flex;flex-wrap:wrap;gap:26px}
46.stat .n{font-size:26px;font-weight:600;line-height:1.1}
47.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
48.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
49margin-right:7px}
50.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
51.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
52table{width:100%;border-collapse:collapse;font-size:13px}
53th,td{text-align:left;padding:6px 12px 6px 0;
54border-bottom:1px solid var(--line);vertical-align:top}
55th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
56letter-spacing:.04em}
57.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
58a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
59.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
60.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
61.bad{color:var(--bad)}
62.note{color:var(--mut);font-size:12px;margin:10px 0 0}
63footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
64color:var(--mut);font-size:12px;line-height:1.7}
65footer b{color:var(--fg);font-weight:600}
66/* loop groups — collapsible (collapsed by default), two-level hierarchy */
67details.loop{margin:24px 0 0}
68summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
69cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
70border-bottom:2px solid var(--fg)}
71summary.loophead::-webkit-details-marker{display:none}
72summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
73font-size:11px;position:relative;top:-1px}
74details.loop[open]>summary.loophead::before{content:"\\25BE"}
75summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
76color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
77summary.loophead .sub{color:var(--mut);font-size:12px}
78summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
79align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
80summary.loophead .glance b{color:var(--fg);font-weight:600}
81/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
82details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
83details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
84font-weight:600;text-transform:uppercase;letter-spacing:.06em}
85/* subsections sit tighter inside a loop; the loop rule carries the weight */
86details.loop section{margin:18px 0 0}
87details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
88/* foldable framing — the MHE notes, one click away, so the page scans clean */
89details.why{margin:9px 0 0}
90details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
91list-style:none}
92details.why>summary::-webkit-details-marker{display:none}
93details.why>summary::before{content:"\\002b why";font-weight:600}
94details.why[open]>summary::before{content:"\\2212 why"}
95details.why .note{margin:6px 0 0}
96"""
97 
98_CI_CLASS = {
99 "passed": "ok",
100 "failed": "bad",
101 "absent": "mut",
102 "timed_out": "warn",
104_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
105 
106 
107def _aware(when: datetime) -> datetime:
108 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
109 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
110 
111 
112def _ago(when: datetime | None, now: datetime) -> str:
113 """A compact 'time since' label (``6h ago``)."""
114 if when is None:
115 return ""
116 secs = (now - _aware(when)).total_seconds()
117 if secs < 90:
118 return "just now"
119 if secs < 5400:
120 return f"{int(secs // 60)}m ago"
121 if secs < 129600:
122 return f"{int(secs // 3600)}h ago"
123 return f"{int(secs // 86400)}d ago"
124 
125 
126def _until(when: datetime | None, now: datetime) -> str:
127 """A compact 'time until' label (``in 18h`` / ``due now``)."""
128 if when is None:
129 return ""
130 secs = (_aware(when) - now).total_seconds()
131 if secs <= 0:
132 return "due now"
133 if secs < 5400:
134 return f"in {int(secs // 60)}m"
135 if secs < 129600:
136 return f"in {int(secs // 3600)}h"
137 return f"in {int(secs // 86400)}d"
138 
139 
140def _dot(kind: str) -> str:
141 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
142 return f'<span class="dot {kind}"></span>'
143 
144 
145def _tag(value: str | None, classes: dict[str, str]) -> str:
146 """A small coloured label for a verdict/CI value, or an em-dash."""
147 if value is None:
148 return '<span class="mut">—</span>'
149 cls = classes.get(value, "mut")
150 return f'<span class="{cls}">{escape(value)}</span>'
151 
152 
153def _stat(n: object, label: str) -> str:
154 return (
155 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
156 f'<div class="l">{escape(label)}</div></div>'
157 )
158 
159 
160def _why(note_html: str) -> str:
161 """Fold a framing note behind a native ``<details>`` so the page scans."""
162 return f'<details class="why"><summary></summary>{note_html}</details>'
163 
164 
165def _glance(n: object, label: str) -> str:
166 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
167 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
168 
169 
170def _header(model: DashboardModel) -> str:
171 now = model.generated_at
172 repos = ", ".join(model.repos_configured) or "none configured"
173 dots = "".join(
174 f"<span>{_dot('ok' if s.ok else 'bad')}"
175 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
176 for s in model.sources
177 )
178 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
179 return (
180 "<header>"
181 "<h1>froot</h1>"
182 '<p class="tag">durable maintenance loops &middot; '
183 "reputation read-model</p>"
184 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
185 f" &middot; generated {escape(stamp)} &middot; "
186 "derived live, stored nowhere</p>"
187 f'<div class="sources">{dots}</div>'
188 "</header>"
189 )
190 
191 
192def _heartbeat(model: DashboardModel) -> str:
193 now = model.generated_at
194 interval = model.scan_interval_seconds
195 
196 def line(loop: ScanLoop) -> str:
197 if loop.live:
198 dot, tail = "ok", ""
199 if loop.last_tick is not None:
200 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
201 last = _ago(loop.last_tick, now)
202 tail = (
203 f' <span class="mut">&middot; last {last}'
204 f" &middot; next {_until(nxt, now)}</span>"
205 )
206 else:
207 dot = "bad" if loop.status in ("terminated", "none") else "warn"
208 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
209 return (
210 f'<div class="row">{_dot(dot)}'
211 f'<span class="mono">{escape(loop.repo)}</span>'
212 f' <span class="mut">{escape(loop.loop)}</span>{tail}</div>'
213 )
214 
215 if not model.scan_loops:
216 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
217 else:
218 body = "".join(line(loop) for loop in model.scan_loops)
219 return f"<section><h2>Is it alive?</h2>{body}</section>"
220 
221 
222def _track_record(model: DashboardModel) -> str:
223 t = model.track_record
224 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
225 ttm = (
226 ""
227 if t.median_ttm_minutes is None
228 else f"{t.median_ttm_minutes:.0f} min"
229 )
230 stats = "".join(
231 (
232 _stat(t.opened, "proposed"),
233 _stat(t.merged, "merged"),
234 _stat(t.open_now, "awaiting"),
235 _stat(t.closed_unmerged, "closed"),
236 _stat(rate, "merge rate"),
237 _stat(ttm, "median time-to-merge"),
238 )
239 )
240 note = (
241 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
242 "npm patch bumps by construction. It counts a human merge, not a "
243 "confirmed good outcome: revert tracking is a later loop, so merge is "
244 "not yet proof of success.</p>"
245 )
246 return (
247 "<section><h2>Track record &middot; the reputation</h2>"
248 f'<div class="stats">{stats}</div>{_why(note)}</section>'
249 )
250 
251 
252def _rate_cell(rate: float | None) -> str:
253 """A class's approval rate as a percent, or an em-dash if none decided."""
254 if rate is None:
255 return '<span class="mut">—</span>'
256 return f"{rate * 100:.0f}%"
257 
258 
259def _earned_cell(g: ClassGate) -> str:
260 """A class's gate standing: a green 'earned', else the muted blocker."""
261 if g.earned:
262 return f'{_dot("ok")}<span class="ok">earned</span>'
263 blocker = escape(g.blocker or "not earned")
264 return f'{_dot("mut")}<span class="mut">{blocker}</span>'
265 
266 
267def _budget_cell(g: ClassGate) -> str:
268 """The gate in steward-time: approvals/week now &rarr; reclaimable/week."""
269 return (
270 f'<span class="mono">{g.approvals_per_week:.1f}</span> appr'
271 f' <span class="mut">&rarr; {g.reclaim_per_week:.1f} reclaimable</span>'
272 )
273 
274 
275def _class_gates(model: DashboardModel) -> str:
276 """The earned-autonomy panel — the shadow gate's per-class standing.
277 
278 Read-only: it renders whether each (repo, loop) class *would* have earned a
279 gate move, and the steward-budget that move would reclaim. Nothing acts.
280 """
281 gates = model.class_gates
282 if not gates:
283 body = (
284 '<p class="note">No classes yet &mdash; a (repo, loop) earns a '
285 "gate move from its own track record.</p>"
286 )
287 else:
288 rows = "".join(
289 "<tr>"
290 f'<td class="mono">{escape(g.repo)}</td>'
291 f'<td class="mut">{escape(g.loop)}</td>'
292 f"<td>{g.decided} in {g.window_days}d</td>"
293 f"<td>{_rate_cell(g.merge_rate)}</td>"
294 f"<td>{_earned_cell(g)}</td>"
295 f"<td>{_budget_cell(g)}</td>"
296 "</tr>"
297 for g in gates
298 )
299 body = (
300 "<table><thead><tr><th>repo</th><th>loop</th><th>decided</th>"
301 "<th>rate</th><th>gate</th><th>budget / week</th></tr></thead>"
302 f"<tbody>{rows}</tbody></table>"
303 )
304 note = (
305 '<p class="note">Each <b>class</b> is one loop on one repo &mdash; a '
306 "distinct trust unit (a security patch is not a version bump, and they "
307 "never share a record). A class earns its gate from a high enough "
308 "<b>approval rate</b> over enough recently-<b>decided</b> PRs; the "
309 "window keeps trust recent, not lifetime. <b>Budget</b> reads the "
310 "gate in steward-time &mdash; approvals/week is what the class costs "
311 "a human now, reclaimable is the clean-and-green slice a gate move "
312 "would hand back. <b>Advisory only</b>: auto-merge is allowlist-gated "
313 "and off, and the approval rate counts a human <i>merge</i>, not a "
314 "confirmed-good outcome &mdash; with no revert tracking yet, a high "
315 "rate can still mask rubber-stamping (&sect;3.7). Rollback tracking "
316 "and sampled review come before anything acts.</p>"
317 )
318 return (
319 "<section><h2>Earned autonomy &middot; the shadow gate</h2>"
320 f"{body}{_why(note)}</section>"
321 )
322 
323 
324def _verification(model: DashboardModel) -> str:
325 v = model.verification
326 stats = "".join(
327 (
328 _stat(v.passed, "CI passed"),
329 _stat(v.failed, "CI failed"),
330 _stat(v.absent, "no checks"),
331 _stat(v.timed_out, "timed out"),
332 _stat(v.unknown, "unknown"),
333 )
334 )
335 if v.with_reading == 0:
336 note = '<p class="note">No CI readings yet.</p>'
337 else:
338 note = _why(
339 f'<p class="note">A real oracle reported on '
340 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
341 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
342 "absent &mdash; not a pass; never conflated.</span>"
343 "</p>"
344 )
345 return (
346 "<section><h2>Verification &middot; CI is the oracle</h2>"
347 f'<div class="stats">{stats}</div>{note}</section>'
348 )
349 
350 
351def _judgment(model: DashboardModel) -> str:
352 j = model.judgment
353 stats = "".join(
354 (
355 _stat(j.clean, "clean"),
356 _stat(j.risky, "risky"),
357 _stat(j.unknown, "unknown"),
358 _stat(j.none, "no verdict"),
359 )
360 )
361 if j.clean_but_failed or j.flagged_but_passed:
362 note = (
363 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
364 "&lsquo;clean&rsquo; bumps whose CI failed, "
365 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
366 )
367 else:
368 note = (
369 '<p class="note">The model&rsquo;s only job is the changelog '
370 "verdict; the spine proposes the bump either way.</p>"
371 )
372 return (
373 "<section><h2>Model judgment &middot; the one model call</h2>"
374 f'<div class="stats">{stats}</div>{_why(note)}</section>'
375 )
376 
377 
378def _shadow_badge(row: BumpRow) -> str:
379 """Whether this open PR *would* auto-merge under its class's grant.
380 
381 Advisory: a green 'would auto-merge' means every condition is met and the
382 human approval is the only thing still in the way; otherwise the first
383 blocker, muted, so the held reason is the thing to fix.
384 """
385 if row.would_auto_merge:
386 return '<span class="ok">would auto-merge</span>'
387 reason = row.held_reason or "held"
388 return f'<span class="mut">held &middot; {escape(reason)}</span>'
389 
⋯ 374 lines hidden (lines 390–763)
390 
391def _gate(model: DashboardModel) -> str:
392 now = model.generated_at
393 if not model.gate:
394 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
395 else:
396 rows = "".join(
397 "<tr>"
398 f'<td class="mono">{escape(row.package)}</td>'
399 f"<td>{escape(_ago(row.opened_at, now))}</td>"
400 f"<td>{_shadow_badge(row)}</td>"
401 f"<td>{_pr_link(row)}</td>"
402 "</tr>"
403 for row in model.gate
404 )
405 note = _why(
406 '<p class="note">The <b>shadow gate</b> column is advisory &mdash; '
407 "froot acts on nothing. A green &lsquo;would auto-merge&rsquo; "
408 "means this PR met its class&rsquo;s grant and the human approval "
409 "is the only remaining step; it rests on the single CI oracle and "
410 "a merge-rate record, which (esp. for a security patch) is not the "
411 "same as a confirmed-good or security-reviewed outcome. You still "
412 "own every merge.</p>"
413 )
414 body = (
415 "<table><thead><tr><th>package</th><th>waiting</th>"
416 "<th>shadow gate</th><th>pr</th>"
417 f"</tr></thead><tbody>{rows}</tbody></table>{note}"
418 )
419 return (
420 "<section><h2>Approval gate &middot; what a human owns</h2>"
421 f"{body}</section>"
422 )
423 
424 
425def _bumps(model: DashboardModel) -> str:
426 now = model.generated_at
427 if not model.bumps:
428 body = '<p class="note">No bumps proposed yet.</p>'
429 else:
430 rows = "".join(
431 "<tr>"
432 f'<td class="mono">{escape(row.package)}</td>'
433 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
434 f"{escape(row.to_version)}</td>"
435 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
436 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
437 f"<td>{_state_tag(row.state)}</td>"
438 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
439 f"<td>{_pr_link(row)}</td>"
440 "</tr>"
441 for row in model.bumps
442 )
443 body = (
444 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
445 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
446 f"<tbody>{rows}</tbody></table>"
447 )
448 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
449 
450 
451def _failures(model: DashboardModel) -> str:
452 if not model.failures:
453 return ""
454 now = model.generated_at
455 rows = "".join(
456 "<tr>"
457 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
458 f"<td>{_state_tag(f.kind)}</td>"
459 f'<td class="mut">{escape(f.reason or "")}</td>'
460 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
461 "</tr>"
462 for f in model.failures
463 )
464 return (
465 "<section><h2>Failures &middot; where the loop did not close</h2>"
466 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
467 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
468 )
469 
470 
471def _telem_glance(model: DashboardModel) -> str:
472 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
473 t = model.telemetry
474 if not t.available:
475 return '<span class="mut">unavailable</span>'
476 return _glance(t.total_spans, f"spans / {t.window_days}d")
477 
478 
479def _telemetry(model: DashboardModel) -> str:
480 """The telemetry body — header lives in the shared loop group above it."""
481 t: RunTelemetry = model.telemetry
482 if not t.available:
483 return (
484 "<section>"
485 '<p class="note">Unavailable (not configured or unreachable). '
486 "GitHub + Temporal carry the dashboard regardless.</p></section>"
487 )
488 now = model.generated_at
489 if t.activities:
490 rows = "".join(
491 "<tr>"
492 f'<td class="mono">{escape(a.name)}</td>'
493 f"<td>{a.count}</td>"
494 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
495 f'<td class="mut">{a.max_ms:.0f} ms</td>'
496 "</tr>"
497 for a in t.activities
498 )
499 table = (
500 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
501 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
502 )
503 else:
504 table = '<p class="note">No froot spans in the window.</p>'
505 summary = (
506 f'<p class="note">{t.total_spans} spans &middot; '
507 f"{t.error_spans} errored &middot; last activity "
508 f"{escape(_ago(t.last_activity, now))} &middot; "
509 f"{t.window_days}-day window.</p>"
510 )
511 return f"<section>{summary}{table}</section>"
512 
513 
514def _review_heartbeat(model: DashboardModel) -> str:
515 now = model.generated_at
516 interval = model.review_interval_seconds
517 
518 def line(loop: ReviewLoop) -> str:
519 if loop.live:
520 dot, tail = "ok", ""
521 if loop.last_tick is not None:
522 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
523 last = _ago(loop.last_tick, now)
524 tail = (
525 f' <span class="mut">&middot; last {last}'
526 f" &middot; next {_until(nxt, now)}</span>"
527 )
528 else:
529 dot = "bad" if loop.status in ("terminated", "none") else "warn"
530 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
531 return (
532 f'<div class="row">{_dot(dot)}'
533 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
534 )
535 
536 if not model.review_loops:
537 body = (
538 '<p class="note">No determinism-review loops running '
539 "(the transitive ring watches the @workflow.defn repos).</p>"
540 )
541 else:
542 body = "".join(line(loop) for loop in model.review_loops)
543 return f"<section><h2>Is it alive?</h2>{body}</section>"
544 
545 
546def _review_record(model: DashboardModel) -> str:
547 r = model.review_record
548 stats = "".join(
549 (
550 _stat(r.reviewed, "reviewed"),
551 _stat(r.flagged, "flagged"),
552 _stat(r.clean, "clean"),
553 _stat(r.hazards, "hazards"),
554 _stat(r.repos_covered, "repos covered"),
555 )
556 )
557 note = (
558 '<p class="note">The transitive ring: it chases first-party helper '
559 "calls out of each workflow to catch a hazard the lexical CI kernel "
560 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
561 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
562 "a later commit?) is a later loop; it needs accumulated history.</p>"
563 )
564 return (
565 "<section><h2>Track record &middot; the reputation</h2>"
566 f'<div class="stats">{stats}</div>{_why(note)}</section>'
567 )
568 
569 
570def _reviews(model: DashboardModel) -> str:
571 now = model.generated_at
572 if not model.reviews:
573 body = '<p class="note">No PRs reviewed yet.</p>'
574 else:
575 rows = "".join(
576 "<tr>"
577 f'<td class="mono">{escape(row.repo)}</td>'
578 f"<td>{_review_pr_link(row)}</td>"
579 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
580 "</td>"
581 f"<td>{_findings_cell(row)}</td>"
582 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
583 "</tr>"
584 for row in model.reviews
585 )
586 body = (
587 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
588 "<th>findings</th><th>reviewed</th></tr></thead>"
589 f"<tbody>{rows}</tbody></table>"
590 )
591 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
592 
593 
594def _footer() -> str:
595 return (
596 "<footer>"
597 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
598 "<b>write authority</b> only: it opens PRs, a human approves every "
599 "merge (commit authority = none). Trust, when any is granted, is "
600 "earned, narrow to a single loop on a single repo (a version bump is "
601 "not a security patch; they never share a record), conditional on its "
602 'environment (judge <span class="mono">gemma4:e4b</span>, '
603 "lockfile-only regen), revocable, and time-expiring. Today it records "
604 "the track "
605 "record and renders a <b>shadow gate</b> &mdash; a dry run of the "
606 "auto-merge decision that still acts on nothing.<br>"
607 "Everything above is derived on this request from GitHub (outcomes) + "
608 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
609 "reload to recompute."
610 "</footer>"
611 )
612 
613 
614def _pr_link(row: BumpRow) -> str:
615 if row.pr_url is None or row.pr_number is None:
616 return '<span class="mut">—</span>'
617 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
618 
619 
620def _review_pr_link(row: ReviewRow) -> str:
621 if row.pr_url is None or row.pr_number is None:
622 return '<span class="mut">—</span>'
623 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
624 
625 
626def _findings_cell(row: ReviewRow) -> str:
627 """A review's findings: 'clean', or the hazard count + rules + comment."""
628 if row.findings == 0:
629 return '<span class="ok">clean</span>'
630 rules = (
631 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
632 if row.rules
633 else ""
634 )
635 comment = (
636 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
637 if row.comment_url
638 else ""
639 )
640 noun = "hazard" if row.findings == 1 else "hazards"
641 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
642 
643 
644def _state_tag(state: str) -> str:
645 cls = {
646 "merged": "ok",
647 "open": "warn",
648 "closed": "mut",
649 "terminated": "bad",
650 "failed": "bad",
651 "timed_out": "bad",
652 "canceled": "warn",
653 }.get(state, "mut")
654 return f'<span class="{cls}">{escape(state)}</span>'
655 
656 
657def _short_id(workflow_id: str) -> str:
658 """Drop the ``froot-bump-`` prefix for a readable failures row."""
659 return workflow_id.removeprefix("froot-bump-")
660 
661 
662def _dep_glance(model: DashboardModel) -> str:
663 """The dependency-patch loop's at-a-glance, shown in its header."""
664 t = model.track_record
665 live = sum(1 for loop in model.scan_loops if loop.live)
666 total = len(model.scan_loops)
667 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
668 items = [
669 _glance(f"{live}/{total}", "live"),
670 _glance(t.opened, "proposed"),
671 _glance(rate, "merged"),
672 ]
673 if model.class_gates:
674 earned = sum(1 for g in model.class_gates if g.earned)
675 items.append(_glance(f"{earned}/{len(model.class_gates)}", "earned"))
676 if t.open_now:
677 items.append(_glance(t.open_now, "awaiting you"))
678 return "".join(items)
679 
680 
681def _det_glance(model: DashboardModel) -> str:
682 """The determinism-review loop's at-a-glance, shown in its header."""
683 r = model.review_record
684 return "".join(
685 (
686 _glance(r.repos_covered, "repos"),
687 _glance(r.reviewed, "reviewed"),
688 _glance(r.hazards, "hazards"),
689 )
690 )
691 
692 
693def _loop(
694 title: str,
695 sub: str,
696 glance: str,
697 sections: tuple[str, ...],
698 *,
699 shared: bool = False,
700) -> str:
701 """A collapsible group: a prominent header + at-a-glance, collapsed.
702 
703 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
704 still shows the headline; expanding reveals the subsections. ``shared`` is a
705 non-loop infra group (telemetry) with a lighter, distinct header.
706 """
707 cls = "loop shared" if shared else "loop"
708 head = (
709 '<summary class="loophead">'
710 f"<h2>{escape(title)}</h2>"
711 f'<span class="sub">{sub}</span>'
712 f'<span class="glance">{glance}</span>'
713 "</summary>"
714 )
715 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
716 
717 
718def page(model: DashboardModel) -> str:
719 """Render the whole dashboard as one self-contained HTML document."""
720 parts = (
721 _header(model),
722 _loop(
723 "Dependency-patch",
724 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
725 _dep_glance(model),
726 (
727 _heartbeat(model),
728 _track_record(model),
729 _class_gates(model),
730 _verification(model),
731 _judgment(model),
732 _gate(model),
733 _bumps(model),
734 _failures(model),
735 ),
736 ),
737 _loop(
738 "Determinism review",
739 "the transitive ring &middot; advisory",
740 _det_glance(model),
741 (
742 _review_heartbeat(model),
743 _review_record(model),
744 _reviews(model),
745 ),
746 ),
747 _loop(
748 "Run telemetry",
749 "ClickHouse &middot; trace-derived, best-effort",
750 _telem_glance(model),
751 (_telemetry(model),),
752 shared=True,
753 ),
754 _footer(),
755 )
756 return (
757 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
758 '<meta name="viewport" content="width=device-width,initial-scale=1">'
759 "<title>froot &middot; read-model</title>"
760 f"<style>{_CSS}</style></head><body><main>"
761 + "".join(parts)
762 + "</main></body></html>"
763 )

Config and wiring — settings.py + server.py

AutonomySettings (FROOT_AUTOMERGE_*) tunes the thresholds and the allowlist, and builds the pure AutonomyPolicy the read-model consumes. The allowlist is a comma-separated list, empty by default — the revocable switch, off until a steward flips it. server.py reads it per request and degrades to conservative defaults so a bad value can never blank the page.

Settings at the edge; the pure policy is built in .policy().

src/froot/config/settings.py · 332 lines
src/froot/config/settings.py332 lines · Python
⋯ 286 lines hidden (lines 1–286)
1"""All deployment config, as pydantic-settings models (env or ``.env``), frozen.
2 
3* :class:`Settings` (``FROOT_*``) — loop config: repositories, scan interval,
4 and which maintenance loops (``FROOT_LOOPS``) to run on them.
5* :class:`TemporalSettings` (``TEMPORAL_*``) — connection: host / namespace /
6 task queue, shared by the worker, the scan starter, and the activity client.
7* :class:`GitHubSettings` — the API token (``FROOT_GITHUB_TOKEN``) as a
8 :class:`~pydantic.SecretStr`, so it is masked in ``repr``, logs, and
9 tracebacks and cannot leak accidentally.
10* :class:`ModelSettings` (``FROOT_OLLAMA_MODEL`` / ``FROOT_OLLAMA_URL``) — the
11 changelog-judge model endpoint.
12* :class:`TelemetrySettings` (``FROOT_OTEL``) — observability toggle.
13 
14Each consumer builds the small model it needs at its point of use; nothing
15secret lives in the repo. ``repos`` is ``NoDecode`` so ``FROOT_REPOS`` is a
16comma-separated list of ``owner/name`` slugs rather than JSON; a slug may carry
17an optional ``@<ecosystem>`` suffix (e.g. ``acme/pylib@uv``), defaulting to npm.
18"""
19 
20from __future__ import annotations
21 
22from typing import Annotated
23 
24from pydantic import Field, SecretStr, field_validator
25from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
26 
27from froot.domain.ecosystem import Ecosystem
28from froot.domain.loop import Loop
29from froot.domain.repo import RepoRef, TargetRepo
30from froot.policy.autonomy import AutonomyPolicy
31from froot.result import Ok
32 
33_DEFAULT_SCAN_INTERVAL_SECONDS = 86_400
34 
35 
36class Settings(BaseSettings):
37 """Non-secret worker config from ``FROOT_*`` (env or ``.env``)."""
38 
39 model_config = SettingsConfigDict(
40 env_prefix="FROOT_",
41 env_file=".env",
42 extra="ignore",
43 frozen=True,
44 )
45 
46 repos: Annotated[tuple[TargetRepo, ...], NoDecode] = Field(min_length=1)
47 scan_interval_seconds: int = Field(
48 default=_DEFAULT_SCAN_INTERVAL_SECONDS, gt=0
49 )
50 # Which maintenance loops to run on each repo. ``FROOT_LOOPS`` is a
51 # comma-separated list of loop names; it defaults to dependency-patch alone,
52 # so an existing deployment keeps running exactly the loop it did before.
53 loops: Annotated[tuple[Loop, ...], NoDecode] = Field(
54 default=(Loop.DEPENDENCY_PATCH,), min_length=1
55 )
56 
57 @field_validator("loops", mode="before")
58 @classmethod
59 def _parse_loops(cls, value: object) -> object:
60 """Parse ``FROOT_LOOPS`` as a comma-separated loop-name list."""
61 if not isinstance(value, str):
62 return value
63 loops: list[Loop] = []
64 for raw in value.split(","):
65 entry = raw.strip()
66 if not entry:
67 continue
68 try:
69 loops.append(Loop(entry))
70 except ValueError:
71 raise ValueError(f"unknown loop: {entry!r}") from None
72 return tuple(loops)
73 
74 @field_validator("repos", mode="before")
75 @classmethod
76 def _parse_repos(cls, value: object) -> object:
77 """Parse ``FROOT_REPOS`` as a comma-separated target list.
78 
79 Each entry is an ``owner/name`` slug, optionally suffixed with
80 ``@<ecosystem>`` (e.g. ``acme/pylib@uv``); the suffix is omitted for the
81 default ``npm``.
82 """
83 if not isinstance(value, str):
84 return value
85 targets: list[TargetRepo] = []
86 for raw in value.split(","):
87 entry = raw.strip()
88 if not entry:
89 continue
90 slug, _, eco = entry.partition("@")
91 match RepoRef.parse(slug):
92 case Ok(ref):
93 pass
94 case _:
95 raise ValueError(f"invalid repo slug: {slug!r}")
96 try:
97 ecosystem = Ecosystem(eco) if eco else Ecosystem.NPM
98 except ValueError:
99 raise ValueError(f"unknown ecosystem: {eco!r}") from None
100 targets.append(TargetRepo(repo=ref, ecosystem=ecosystem))
101 return tuple(targets)
102 
103 
104class TemporalSettings(BaseSettings):
105 """Temporal connection config from ``TEMPORAL_*`` (env or ``.env``).
106 
107 The same image runs anywhere by configuring these; the in-cluster
108 deployment sets them to the cluster's frontend, namespace, and queue.
109 """
110 
111 model_config = SettingsConfigDict(
112 env_prefix="TEMPORAL_",
113 env_file=".env",
114 extra="ignore",
115 frozen=True,
116 )
117 
118 host: str = Field(default="localhost:7233", min_length=1)
119 namespace: str = Field(default="default", min_length=1)
120 task_queue: str = Field(default="froot", min_length=1)
121 
122 
123class GitHubSettings(BaseSettings):
124 """GitHub credentials, from ``FROOT_GITHUB_TOKEN``.
125 
126 The token is a :class:`~pydantic.SecretStr`, so it is masked in ``repr``,
127 logs, and tracebacks and cannot leak accidentally; call
128 ``github_token.get_secret_value()`` only where the real value is sent.
129 """
130 
131 model_config = SettingsConfigDict(
132 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
133 )
134 
135 github_token: SecretStr | None = None
136 
137 
138class ModelSettings(BaseSettings):
139 """The changelog-judge model endpoint (a local Ollama by default)."""
140 
141 model_config = SettingsConfigDict(
142 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
143 )
144 
145 ollama_model: str = Field(default="gemma4:e4b", min_length=1)
146 ollama_url: str = Field(default="http://localhost:11434/v1", min_length=1)
147 
148 
149class TelemetrySettings(BaseSettings):
150 """OpenTelemetry toggle — off unless ``FROOT_OTEL`` is truthy."""
151 
152 model_config = SettingsConfigDict(
153 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
154 )
155 
156 otel: bool = False
157 
158 @field_validator("otel", mode="before")
159 @classmethod
160 def _blank_is_off(cls, value: object) -> object:
161 """Treat an empty/whitespace ``FROOT_OTEL`` as off, not an error."""
162 if isinstance(value, str) and not value.strip():
163 return False
164 return value
165 
166 
167class ClickHouseSettings(BaseSettings):
168 """ClickHouse (the run ledger) connection for the dashboard read-model.
169 
170 Every field is optional: when ``FROOT_CLICKHOUSE_URL`` is unset the
171 dashboard renders the run-telemetry panel as *unavailable* rather than
172 failing. That panel is best-effort enrichment — GitHub (outcomes) and
173 Temporal (live runs) are the dependable sources; ClickHouse only adds
174 trace-derived run telemetry, on a 3-day TTL at that. The password is a
175 :class:`~pydantic.SecretStr`.
176 """
177 
178 model_config = SettingsConfigDict(
179 env_prefix="FROOT_CLICKHOUSE_",
180 env_file=".env",
181 extra="ignore",
182 frozen=True,
183 )
184 
185 # Host only, e.g. ``http://clickhouse:8123`` — NEVER embed credentials as
186 # userinfo (``http://user:pw@host``): they belong in ``user``/``password``
187 # so they stay out of error strings the dashboard may surface.
188 url: str | None = None
189 user: str = Field(default="default", min_length=1)
190 password: SecretStr | None = None
191 database: str = Field(default="default", min_length=1)
192 
193 
194class DashboardSettings(BaseSettings):
195 """The read-model dashboard's HTTP surface, served by the worker.
196 
197 A read-only page the worker serves on ``FROOT_DASHBOARD_PORT``; reach it
198 with ``kubectl port-forward``. It derives everything on request and stores
199 nothing (froot's own derived-state invariant), so it is safe to leave on.
200 """
201 
202 model_config = SettingsConfigDict(
203 env_prefix="FROOT_DASHBOARD_",
204 env_file=".env",
205 extra="ignore",
206 frozen=True,
207 )
208 
209 enabled: bool = True
210 # Bind all interfaces so an in-cluster ``kubectl port-forward`` reaches it.
211 host: str = Field(default="0.0.0.0", min_length=1)
212 port: int = Field(default=8080, gt=0, le=65535)
213 
214 @field_validator("enabled", mode="before")
215 @classmethod
216 def _blank_is_on(cls, value: object) -> object:
217 """Treat an empty/whitespace value as the default (on), not an error."""
218 if isinstance(value, str) and not value.strip():
219 return True
220 return value
221 
222 
223class ReviewSettings(BaseSettings):
224 """The determinism-reviewer loop (``FROOT_REVIEW_*``).
225 
226 The transitive ring: a per-repo loop that polls open PRs and leaves an
227 advisory comment when a workflow reaches a determinism hazard through a
228 first-party helper (``depth`` call levels) or a risky third-party import.
229 Advisory only — the blocking gate is the kernel's ``Determinism`` CI check.
230 """
231 
232 model_config = SettingsConfigDict(
233 env_prefix="FROOT_REVIEW_",
234 env_file=".env",
235 extra="ignore",
236 frozen=True,
237 )
238 
239 enabled: bool = True
240 # PRs merge fast, but this loop is advisory (not a gate), so a little
241 # latency is fine — it never needs to win the merge race.
242 poll_interval_seconds: int = Field(default=300, gt=0)
243 # How many first-party call levels to chase out of each workflow method.
244 depth: int = Field(default=2, ge=1, le=4)
245 
246 @field_validator("enabled", mode="before")
247 @classmethod
248 def _blank_is_on(cls, value: object) -> object:
249 """Treat an empty/whitespace value as the default (on), not an error."""
250 if isinstance(value, str) and not value.strip():
251 return True
252 return value
253 
254 
255class BehaviorSettings(BaseSettings):
256 """Loop-hygiene toggles (``FROOT_*``), both defaulting on.
257 
258 * ``close_on_red`` — close a bump's PR (and delete its branch) when its CI
259 comes back red, so no rotting red proposal is left for the human. The
260 outcome is still recorded either way. Read at dispatch and pinned onto the
261 bump's params, so an in-flight bump keeps the value it started with.
262 * ``reconcile`` — each scan tick, close froot PRs a newer patch has
263 superseded or the base has already satisfied. Read by the reconcile
264 activity, which no-ops when it is off.
265 
266 Both close-then-delete a branch, so they are the destructive knobs; default
267 on (the SPEC's behavior), but here to disable for cautious adoption on a
268 flaky-CI repo.
269 """
270 
271 model_config = SettingsConfigDict(
272 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
273 )
274 
275 close_on_red: bool = True
276 reconcile: bool = True
277 
278 @field_validator("close_on_red", "reconcile", mode="before")
279 @classmethod
280 def _blank_is_on(cls, value: object) -> object:
281 """Treat an empty/whitespace value as the default (on), not an error."""
282 if isinstance(value, str) and not value.strip():
283 return True
284 return value
285 
286 
287class AutonomySettings(BaseSettings):
288 """The earned-autonomy thresholds (``FROOT_AUTOMERGE_*``), advisory today.
289 
290 These tune the *shadow gate*: the dashboard reads them to decide whether a
291 (repo, loop) class has earned its gate move and whether each open PR would
292 auto-merge under that grant. Nothing acts on the verdict yet — froot stays
293 record-only — so the defaults are deliberately conservative and the
294 allowlist is empty, the revocable switch left off until a steward flips it.
295 
296 * ``min_rate`` / ``min_decided`` / ``window_days`` — the track-record bar a
297 class must clear, measured over a recent window (trust is recent, §2.11).
298 * ``allowlist`` (``FROOT_AUTOMERGE_ALLOWLIST``) — a comma-separated list of
299 ``owner/name`` slugs a steward has opted into; ``NoDecode`` so it is a
300 plain list, not JSON. Empty by default: no class can ride the grant.
301 """
302 
303 model_config = SettingsConfigDict(
304 env_prefix="FROOT_AUTOMERGE_",
305 env_file=".env",
306 extra="ignore",
307 frozen=True,
308 )
309 
310 min_rate: float = Field(default=0.95, ge=0.0, le=1.0)
311 min_decided: int = Field(default=5, ge=1)
312 window_days: int = Field(default=90, gt=0)
313 allowlist: Annotated[tuple[str, ...], NoDecode] = ()
314 
315 @field_validator("allowlist", mode="before")
316 @classmethod
317 def _parse_allowlist(cls, value: object) -> object:
318 """Parse the allowlist as a comma-separated ``owner/name`` list."""
319 if not isinstance(value, str):
320 return value
321 return tuple(
322 entry.strip() for entry in value.split(",") if entry.strip()
323 )
324 
325 def policy(self) -> AutonomyPolicy:
326 """Build the pure :class:`AutonomyPolicy` the read-model consumes."""
327 return AutonomyPolicy(
328 min_rate=self.min_rate,
329 min_decided=self.min_decided,
330 window_days=self.window_days,
331 allowlisted_repos=frozenset(self.allowlist),
332 )

The per-request policy, with a safe fallback.

src/froot/dashboard/server.py · 201 lines
src/froot/dashboard/server.py201 lines · Python
⋯ 65 lines hidden (lines 1–65)
1"""The dashboard HTTP server — a tiny dependency-free asyncio responder.
2 
3Runs on the worker's own event loop (same process, same pod) and reuses the
4worker's connected Temporal client. On each GET it fans the three readers out
5concurrently, assembles the view, and renders one self-contained page; it stores
6nothing between requests. GET-only, ``Connection: close``, ``Cache-Control:
7no-store`` — built for a single viewer behind ``kubectl port-forward``, not the
8public internet. Every reader degrades to an error string, so a source being
9down yields a page with a red dot, never a crash.
10"""
11 
12from __future__ import annotations
13 
14import asyncio
15import contextlib
16import logging
17from datetime import UTC, datetime
18from typing import TYPE_CHECKING, Final
19 
20from froot.config.settings import (
21 AutonomySettings,
22 DashboardSettings,
23 ReviewSettings,
24 Settings,
26from froot.dashboard import (
27 clickhouse_source,
28 github_source,
29 read_model,
30 render,
31 temporal_source,
33from froot.domain.loop import Loop
34from froot.policy.autonomy import AutonomyPolicy
35 
36if TYPE_CHECKING:
37 from temporalio.client import Client
38 
39_log = logging.getLogger("froot.dashboard")
40 
41_READ_TIMEOUT: Final = 15.0
42_MAX_HEAD_BYTES: Final = 64 * 1024
43# Settings' own default, repeated here so a missing FROOT_REPOS still renders.
44_DEFAULT_INTERVAL: Final = 86_400
45_DEFAULT_REVIEW_INTERVAL: Final = 300
46 
47 
48def _config() -> tuple[tuple[str, ...], tuple[Loop, ...], int]:
49 """The watched repos, active loops, and scan interval (empty if unset)."""
50 try:
51 settings = Settings()
52 except Exception: # FROOT_REPOS unset/invalid — show an empty heartbeat
53 return (), (Loop.DEPENDENCY_PATCH,), _DEFAULT_INTERVAL
54 repos = tuple(target.repo.slug for target in settings.repos)
55 return repos, settings.loops, settings.scan_interval_seconds
56 
57 
58def _review_interval() -> int:
59 """The determinism-review poll cadence, degrading to the default."""
60 try:
61 return ReviewSettings().poll_interval_seconds
62 except Exception: # never let a config read fail the page
63 return _DEFAULT_REVIEW_INTERVAL
64 
65 
66def _autonomy_policy() -> AutonomyPolicy:
67 """The earned-autonomy thresholds, degrading to safe defaults.
68 
69 A bad ``FROOT_AUTOMERGE_*`` value must never blank the page: the fallback
70 is the conservative default with an empty allowlist, so the shadow gate
71 simply holds everything rather than erroring.
72 """
73 try:
74 return AutonomySettings().policy()
75 except Exception: # never let a config read fail the page
76 return AutonomyPolicy()
77 
78 
79async def build_html(client: Client) -> str:
80 """Derive the whole view live and render it (the per-request work)."""
81 now = datetime.now(UTC)
82 repos, loops, interval = _config()
⋯ 119 lines hidden (lines 83–201)
83 github_result, temporal_result, telemetry_result = await asyncio.gather(
84 github_source.fetch(repos),
85 temporal_source.fetch(client),
86 clickhouse_source.fetch(),
87 )
88 model = read_model.assemble(
89 now=now,
90 repos=repos,
91 loops=loops,
92 policy=_autonomy_policy(),
93 scan_interval_seconds=interval,
94 review_interval_seconds=_review_interval(),
95 github=github_result,
96 temporal=temporal_result,
97 telemetry=telemetry_result,
98 )
99 return render.page(model)
100 
101 
102def _parse_path(request_line: bytes) -> tuple[str, str]:
103 """Return ``(method, path)`` from a raw HTTP request line."""
104 parts = request_line.decode("latin-1", "replace").split()
105 if len(parts) < 2:
106 return "", "/"
107 return parts[0].upper(), parts[1].split("?", 1)[0]
108 
109 
110async def _drain_headers(reader: asyncio.StreamReader) -> None:
111 """Read and discard the request headers, bounded in size and time."""
112 total = 0
113 while True:
114 line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
115 total += len(line)
116 if line in (b"\r\n", b"\n", b"") or total > _MAX_HEAD_BYTES:
117 return
118 
119 
120async def _respond(
121 writer: asyncio.StreamWriter,
122 status: str,
123 content_type: str,
124 body: bytes,
125) -> None:
126 """Write a complete HTTP/1.1 response and close the connection."""
127 head = (
128 f"HTTP/1.1 {status}\r\n"
129 f"Content-Type: {content_type}\r\n"
130 f"Content-Length: {len(body)}\r\n"
131 "Cache-Control: no-store\r\n"
132 "Connection: close\r\n\r\n"
133 ).encode("latin-1")
134 writer.write(head + body)
135 await writer.drain()
136 
137 
138async def _handle(
139 reader: asyncio.StreamReader,
140 writer: asyncio.StreamWriter,
141 client: Client,
142) -> None:
143 """Serve one request: route a GET to the dashboard, else 404/405/500."""
144 try:
145 request_line = await asyncio.wait_for(reader.readline(), _READ_TIMEOUT)
146 await _drain_headers(reader)
147 method, path = _parse_path(request_line)
148 if method != "GET":
149 await _respond(writer, "405 Method Not Allowed", "text/plain", b"")
150 elif path in ("/", "/index.html", "/dashboard"):
151 html = await build_html(client)
152 await _respond(
153 writer, "200 OK", "text/html; charset=utf-8", html.encode()
154 )
155 elif path == "/healthz":
156 await _respond(writer, "200 OK", "text/plain", b"ok")
157 else:
158 await _respond(writer, "404 Not Found", "text/plain", b"")
159 except (TimeoutError, ConnectionError):
160 pass
161 except Exception as exc: # never let a request take the worker down
162 _log.exception("dashboard request failed")
163 with contextlib.suppress(Exception):
164 await _respond(
165 writer,
166 "500 Internal Server Error",
167 "text/html; charset=utf-8",
168 _error_html(exc).encode(),
169 )
170 finally:
171 with contextlib.suppress(Exception):
172 writer.close()
173 await writer.wait_closed()
174 
175 
176def _error_html(exc: Exception) -> str:
177 """A minimal error page (the detail is for a human at a port-forward)."""
178 from html import escape
179 
180 return (
181 "<!doctype html><meta charset=utf-8><title>froot &middot; error</title>"
182 '<body style="font:15px system-ui;max-width:640px;margin:40px auto">'
183 "<h1>froot dashboard error</h1><p>Could not build the read-model.</p>"
184 f"<pre>{escape(type(exc).__name__)}: {escape(str(exc))}</pre></body>"
185 )
186 
187 
188async def start(client: Client) -> asyncio.Server:
189 """Start the dashboard server on the configured host/port (returns it)."""
190 settings = DashboardSettings()
191 
192 async def on_connect(
193 reader: asyncio.StreamReader, writer: asyncio.StreamWriter
194 ) -> None:
195 await _handle(reader, writer, client)
196 
197 server = await asyncio.start_server(
198 on_connect, host=settings.host, port=settings.port
199 )
200 _log.info("dashboard listening on %s:%d", settings.host, settings.port)
201 return server

Run-ledger instrumentation — telemetry.py + activities.py

A second, independent beat: make the signal stage legible. scan_candidates now reports its selectivity — how much upstream signal it considered versus how much it kept — so a tick that proposes nothing is still visible in the ledger.

set_span_attributes is a thin helper that writes froot-namespaced attributes onto the activity's span. It lazy-imports OpenTelemetry and no-ops when FROOT_OTEL is off, so nothing enters the Temporal workflow sandbox and local runs pay nothing.

No-op early-return, then a lazy otel import.

src/froot/adapters/telemetry.py · 156 lines
src/froot/adapters/telemetry.py156 lines · Python
⋯ 42 lines hidden (lines 1–42)
1"""OpenTelemetry wiring: a tracer provider, SDK metrics, and instrumentation.
2 
3The run-telemetry half of "derive, never store": traces and the Temporal SDK's
4runtime metrics export OTLP/HTTP to the in-cluster collector, which adds the
5ClickStack token on forward. Metrics are
6CUMULATIVE to match what is already in ClickStack.
7 
8All of it is gated on :class:`~froot.config.settings.TelemetrySettings`
9(``FROOT_OTEL``) and is a no-op when off, so tests and local runs stay
10telemetry-free (no exporter threads). It is never imported by a workflow- or
11activity-decorated module, so no OpenTelemetry import enters the Temporal
12workflow sandbox graph.
13"""
14 
15from __future__ import annotations
16 
17from datetime import timedelta
18from typing import TYPE_CHECKING
19 
20from froot.config.settings import TelemetrySettings
21 
22if TYPE_CHECKING:
23 from collections.abc import Sequence
24 
25 import httpx
26 from temporalio.client import Interceptor as ClientInterceptor
27 from temporalio.runtime import Runtime
28 
29# In-cluster collector. Both signals export
30# OTLP/HTTP :4318; the collector adds the ClickStack token on forward.
31_COLLECTOR = "temporal-otel-collector.temporal.svc.cluster.local"
32_TRACES_ENDPOINT = f"http://{_COLLECTOR}:4318/v1/traces"
33_METRICS_ENDPOINT = f"http://{_COLLECTOR}:4318/v1/metrics"
34 
35_tracing_configured = False
36 
37 
38def otel_enabled() -> bool:
39 """True when telemetry is on (the Deployments set ``FROOT_OTEL``)."""
40 return TelemetrySettings().otel
41 
42 
43def set_span_attributes(**attributes: int | float | str | bool) -> None:
44 """Annotate the activity's current span with froot-namespaced attributes.
45 
46 A thin, dependency-light way for an activity to record *what it decided* —
47 e.g. how many upgrades it saw versus how many candidates it kept — onto the
48 span the Temporal tracing interceptor already opened. Keys are prefixed
49 ``froot.`` so they never collide with the interceptor's own attributes.
50 
51 Lazy-imports OpenTelemetry inside the body, so no otel import enters any
52 module's top-level graph (the Temporal workflow sandbox stays clean), and is
53 a no-op when ``FROOT_OTEL`` is off — so tests and local runs add no spans
54 and pay nothing. Call it from an activity body, never from a workflow.
55 """
56 if not otel_enabled():
57 return
58 from opentelemetry import trace
59 
60 span = trace.get_current_span()
61 for key, value in attributes.items():
62 span.set_attribute(f"froot.{key}", value)
⋯ 94 lines hidden (lines 63–156)
63 
64 
65def setup_tracing(service_name: str) -> None:
66 """Install a global TracerProvider exporting OTLP/HTTP to the collector.
67 
68 Gated on ``FROOT_OTEL`` and idempotent: the provider (and its one background
69 export thread) is built at most once per process.
70 
71 Args:
72 service_name: The ``service.name`` resource attribute for this process.
73 """
74 global _tracing_configured
75 if _tracing_configured or not otel_enabled():
76 return
77 from opentelemetry import trace
78 from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
79 OTLPSpanExporter,
80 )
81 from opentelemetry.sdk.resources import SERVICE_NAME, Resource
82 from opentelemetry.sdk.trace import TracerProvider
83 from opentelemetry.sdk.trace.export import BatchSpanProcessor
84 
85 provider = TracerProvider(
86 resource=Resource.create({SERVICE_NAME: service_name})
87 )
88 provider.add_span_processor(
89 BatchSpanProcessor(OTLPSpanExporter(endpoint=_TRACES_ENDPOINT))
90 )
91 trace.set_tracer_provider(provider)
92 _tracing_configured = True
93 
94 
95def tracing_interceptors() -> Sequence[ClientInterceptor]:
96 """Temporal interceptors that carry trace context across the boundary.
97 
98 ``always_create_workflow_spans=True`` so the schedule-/loop-started
99 workflows (the scan loop) are traced too. Empty when telemetry is off.
100 """
101 if not otel_enabled():
102 return []
103 from temporalio.contrib.opentelemetry import TracingInterceptor
104 
105 return [TracingInterceptor(always_create_workflow_spans=True)]
106 
107 
108def metrics_runtime() -> Runtime | None:
109 """A Temporal Runtime that pushes SDK metrics OTLP, or ``None`` (off).
110 
111 ``None`` is accepted by ``Client.connect(runtime=...)`` (default runtime).
112 """
113 if not otel_enabled():
114 return None
115 from temporalio.runtime import (
116 OpenTelemetryConfig,
117 OpenTelemetryMetricTemporality,
118 Runtime,
119 TelemetryConfig,
120 )
121 
122 return Runtime(
123 telemetry=TelemetryConfig(
124 metrics=OpenTelemetryConfig(
125 url=_METRICS_ENDPOINT,
126 metric_temporality=OpenTelemetryMetricTemporality.CUMULATIVE,
127 metric_periodicity=timedelta(seconds=30),
128 http=True,
129 )
130 )
131 )
132 
133 
134def instrument_httpx(client: httpx.Client) -> None:
135 """Instrument one httpx client so W3C traceparent rides outbound calls."""
136 if not otel_enabled():
137 return
138 from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
139 
140 HTTPXClientInstrumentor.instrument_client(client)
141 
142 
143def shutdown_tracing() -> None:
144 """Flush + shut down the tracer provider on graceful termination.
145 
146 The BatchSpanProcessor buffers spans (~5s) and Python's ``atexit`` does NOT
147 run on an unhandled SIGTERM, so without this the worker's last span batch is
148 dropped on every (Recreate) rollout.
149 """
150 if not otel_enabled():
151 return
152 from opentelemetry import trace
153 
154 shutdown = getattr(trace.get_tracer_provider(), "shutdown", None)
155 if shutdown is not None:
156 shutdown()

scan_candidates: emit the considered/selected/dropped counts as span attributes and a structured scan_tick log.

src/froot/workflow/activities.py · 473 lines
src/froot/workflow/activities.py473 lines · Python
⋯ 113 lines hidden (lines 1–113)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.candidate import Candidate
22from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
23from froot.domain.ci import CIStatus
24from froot.domain.determinism import AnalysisResult, FrontierVerdict
25from froot.domain.loop import Loop
26from froot.domain.pull_request import PullRequestRef
27from froot.domain.repo import TargetRepo
28from froot.policy.compose import pr_labels, pull_request_draft
29from froot.policy.determinism import analyze_workflow_surface
30from froot.policy.naming import (
31 branch_name,
32 bump_workflow_id,
33 pr_review_workflow_id,
35from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
36from froot.workflow.types import (
37 AdjudicateInput,
38 CiCheckInput,
39 CloseInput,
40 DispatchInput,
41 DispatchReviewInput,
42 JudgeInput,
43 OpenPrInput,
44 PostReviewInput,
45 PrReviewParams,
46 ReconcileInput,
47 RecordInput,
48 ScanCandidatesInput,
50 
51if TYPE_CHECKING:
52 from froot.ports.protocols import PackageManager
53 
54_log = logging.getLogger("froot.outcome")
55_review_log = logging.getLogger("froot.review")
56_reconcile_log = logging.getLogger("froot.reconcile")
57_scan_log = logging.getLogger("froot.scan")
58 
59 
60def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
61 """The directory the manifest lives in (a monorepo subdir, or the root)."""
62 return workspace / target.manifest_dir if target.manifest_dir else workspace
63 
64 
65async def _select_candidates(
66 loop: Loop,
67 target: TargetRepo,
68 package_manager: PackageManager,
69 manifest_dir: Path,
70) -> tuple[int, tuple[Candidate, ...]]:
71 """Gather this loop's signal from the checkout and select its candidates.
72 
73 The one genuinely per-loop seam: dependency-patch reads the available
74 upgrades and picks the highest patch; security-patch reads the installed,
75 asks OSV for advisories, and picks the lowest version clearing each. The
76 impure sources are lazy-imported per arm so neither drags the other's stack
77 into a sandbox. Both feed a pure selection policy.
78 
79 Returns ``(considered, candidates)`` — ``considered`` is the size of the
80 upstream signal (available upgrades / advisories found) so the scan can make
81 its selectivity legible (how much was seen versus how much was kept).
82 """
83 match loop:
84 case Loop.DEPENDENCY_PATCH:
85 from froot.policy.candidates import select_patch_candidates
86 
87 upgrades = await package_manager.list_upgrades(target, manifest_dir)
88 return len(upgrades), select_patch_candidates(upgrades)
89 case Loop.SECURITY_PATCH:
90 return await _select_security_candidates(
91 target, package_manager, manifest_dir
92 )
93 assert_never(loop)
94 
95 
96async def _select_security_candidates(
97 target: TargetRepo, package_manager: PackageManager, manifest_dir: Path
98) -> tuple[int, tuple[Candidate, ...]]:
99 """Security signal: installed set, OSV advisories, clearing targets.
100 
101 ``considered`` is the count of advisories OSV returned for the installed
102 set — the vulnerabilities in scope this tick, before selection narrows to
103 the ones a forward-stable bump can actually clear.
104 """
105 from froot.adapters.osv import OsvAdvisorySource
106 from froot.policy.candidates import select_security_candidates
107 
108 installed = await package_manager.list_installed(target, manifest_dir)
109 advisories = await OsvAdvisorySource().advisories(installed)
110 return len(advisories), select_security_candidates(installed, advisories)
111 
112 
113@activity.defn
114async def scan_candidates(
115 params: ScanCandidatesInput,
116) -> tuple[Candidate, ...]:
117 """Check out the repo and select this loop's candidates.
118 
119 Emits the tick's selectivity — how much upstream signal was considered
120 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
121 and as a structured ``scan_tick`` log, so the signal stage is legible in the
122 run ledger even on a tick that proposes nothing.
123 """
124 from froot.adapters.github import GitHubForge
125 from froot.adapters.registry import package_manager_for
126 from froot.adapters.telemetry import set_span_attributes
127 
128 forge = GitHubForge()
129 package_manager = package_manager_for(params.target.ecosystem)
130 with tempfile.TemporaryDirectory() as tmp:
131 workspace = Path(tmp)
132 await forge.checkout(params.target, workspace)
133 considered, candidates = await _select_candidates(
134 params.loop,
135 params.target,
136 package_manager,
137 _manifest_dir(params.target, workspace),
138 )
139 selected = len(candidates)
140 dropped = max(considered - selected, 0)
141 set_span_attributes(
142 scan_loop=params.loop.value,
143 scan_repo=params.target.repo.slug,
144 scan_considered=considered,
145 scan_selected=selected,
146 scan_dropped=dropped,
147 )
148 _scan_log.info(
149 json.dumps(
150 {
151 "event": "scan_tick",
152 "loop": params.loop.value,
153 "repo": params.target.repo.slug,
154 "considered": considered,
155 "selected": selected,
156 "dropped": dropped,
157 }
158 )
159 )
160 return candidates
⋯ 313 lines hidden (lines 161–473)
161 
162 
163@activity.defn
164async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
165 """Fetch the candidate's changelog and get the model's typed verdict.
166 
167 The model is froot's one thin, non-load-bearing judgment: a clean verdict
168 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
169 erroring must not stall the spine. A judge failure degrades to
170 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
171 dashboard records the verdict as unknown — rather than failing (and then
172 retrying) the activity. Only the model call is guarded; the fetch is already
173 best-effort (returns ``None``, not an exception). The loop selects what the
174 model is asked (clean-patch vs breaking-change-on-a-security-bump).
175 """
176 from froot.adapters.changelog_http import HttpChangelogSource
177 from froot.adapters.model_judge import PydanticAiJudge
178 
179 changelog = await HttpChangelogSource().fetch(params.candidate)
180 if changelog is None:
181 return UnknownVerdict(rationale="No changelog could be fetched.")
182 try:
183 return await PydanticAiJudge().judge(changelog, params.loop)
184 except Exception as exc:
185 activity.logger.warning(
186 "changelog judge unavailable for %s; degrading to unknown: %r",
187 params.candidate.package,
188 exc,
189 )
190 return UnknownVerdict(
191 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
192 )
193 
194 
195@activity.defn
196async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
197 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
198 from froot.adapters.github import GitHubForge
199 from froot.adapters.registry import package_manager_for
200 
201 forge = GitHubForge()
202 package_manager = package_manager_for(params.target.ecosystem)
203 branch = branch_name(params.candidate, params.loop)
204 existing = await forge.find_open_pull_request(params.target, branch)
205 if existing is not None:
206 return existing
207 draft = pull_request_draft(
208 params.target, params.candidate, params.verdict, params.loop
209 )
210 with tempfile.TemporaryDirectory() as tmp:
211 workspace = Path(tmp)
212 await forge.checkout(params.target, workspace)
213 await package_manager.apply_patch_bump(
214 params.candidate, _manifest_dir(params.target, workspace)
215 )
216 await forge.push_branch(workspace, branch, draft.title)
217 return await forge.open_pull_request(params.target, draft)
218 
219 
220@activity.defn
221async def check_ci(params: CiCheckInput) -> CIStatus:
222 """Read the repo's CI status for the PR's head commit (the oracle)."""
223 from froot.adapters.github import GitHubForge
224 
225 return await GitHubForge().ci_status(params.target, params.head_sha)
226 
227 
228@activity.defn
229async def record_outcome(params: RecordInput) -> None:
230 """Label the PR and log the run telemetry — the signal-update."""
231 from froot.adapters.github import GitHubForge
232 
233 outcome = params.outcome
234 await GitHubForge().add_labels(
235 params.target, outcome.pr.number, pr_labels(params.loop)
236 )
237 _log.info(
238 json.dumps(
239 {
240 "event": "loop_outcome",
241 "loop": params.loop.value,
242 "repo": params.target.repo.slug,
243 "package": outcome.candidate.package,
244 "from": str(outcome.candidate.current),
245 "to": str(outcome.candidate.target),
246 "changelog": outcome.verdict.kind,
247 "ci": outcome.ci.kind,
248 "ci_passed": outcome.ci_passed,
249 "pr": outcome.pr.number,
250 "pr_url": outcome.pr.url,
251 }
252 )
253 )
254 
255 
256@activity.defn
257async def dispatch_bump(params: DispatchInput) -> None:
258 """Start the bump loop for a candidate (idempotent per bump identity).
259 
260 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
261 the bump's params — so the running workflow never reads config itself and an
262 in-flight bump keeps the value it was dispatched with.
263 """
264 from temporalio.common import WorkflowIDReusePolicy
265 from temporalio.exceptions import WorkflowAlreadyStartedError
266 
267 from froot.config.settings import BehaviorSettings
268 from froot.workflow.bump_workflow import BumpWorkflow
269 from froot.workflow.temporal_client import client, task_queue
270 from froot.workflow.types import BumpParams
271 
272 temporal = await client()
273 try:
274 await temporal.start_workflow(
275 BumpWorkflow.run,
276 BumpParams(
277 target=params.target,
278 candidate=params.candidate,
279 close_on_red=BehaviorSettings().close_on_red,
280 loop=params.loop,
281 ),
282 id=bump_workflow_id(params.target, params.candidate, params.loop),
283 task_queue=task_queue(),
284 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
285 )
286 except WorkflowAlreadyStartedError:
287 # This bump already has a loop (running or completed) — a no-op, so
288 # re-scanning never opens a second PR for the same bump.
289 return
290 
291 
292@activity.defn
293async def close_pull_request(params: CloseInput) -> None:
294 """Comment why, then close a red bump's PR and delete its branch.
295 
296 The note goes through the idempotent ``upsert_issue_comment`` and the close
297 itself is idempotent, so a retried close edits its comment in place and
298 never double-posts. The bump's record step still runs after this, so the red
299 outcome is logged either way.
300 """
301 from froot.adapters.github import GitHubForge
302 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
303 
304 forge = GitHubForge()
305 body = closed_on_red_comment(params.failing)
306 await forge.upsert_issue_comment(
307 params.target, params.pr.number, CLOSE_MARKER, body
308 )
309 await forge.close_pull_request(
310 params.target, params.pr.number, params.pr.branch
311 )
312 _log.info(
313 json.dumps(
314 {
315 "event": "pr_closed",
316 "loop": params.loop.value,
317 "reason": "ci_red",
318 "repo": params.target.repo.slug,
319 "pr": params.pr.number,
320 "pr_url": params.pr.url,
321 "failing": list(params.failing),
322 }
323 )
324 )
325 
326 
327@activity.defn
328async def reconcile_open_prs(params: ReconcileInput) -> int:
329 """Close this loop's PRs that a newer candidate or the base has overtaken.
330 
331 Self-contained: lists the repo's open PRs, re-derives this loop's live
332 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
333 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
334 loop's PRs to close, and closes each (deleting its branch). Scoped to the
335 loop's own branch namespace, so the two loops never reconcile each other's
336 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
337 """
338 from froot.adapters.github import GitHubForge
339 from froot.adapters.registry import package_manager_for
340 from froot.config.settings import BehaviorSettings
341 from froot.policy.compose import CLOSE_MARKER
342 from froot.policy.reconcile import reconciliations
343 
344 if not BehaviorSettings().reconcile:
345 return 0
346 
347 target, loop = params.target, params.loop
348 forge = GitHubForge()
349 package_manager = package_manager_for(target.ecosystem)
350 open_prs = await forge.list_open_pull_requests(target)
351 with tempfile.TemporaryDirectory() as tmp:
352 workspace = Path(tmp)
353 await forge.checkout(target, workspace)
354 _considered, candidates = await _select_candidates(
355 loop, target, package_manager, _manifest_dir(target, workspace)
356 )
357 closures = reconciliations(open_prs, candidates, loop)
358 for closure in closures:
359 await forge.upsert_issue_comment(
360 target, closure.pr.number, CLOSE_MARKER, closure.comment
361 )
362 await forge.close_pull_request(
363 target, closure.pr.number, closure.pr.branch
364 )
365 if closures:
366 _reconcile_log.info(
367 json.dumps(
368 {
369 "event": "reconcile",
370 "loop": loop.value,
371 "repo": target.repo.slug,
372 "closed": len(closures),
373 "prs": [closure.pr.number for closure in closures],
374 }
375 )
376 )
377 return len(closures)
378 
379 
380# ── The determinism reviewer (the transitive ring) ──────────────────────────
381@activity.defn
382async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
383 """List the repo's open PRs for the determinism reviewer to consider."""
384 from froot.adapters.github import GitHubForge
385 
386 return await GitHubForge().list_open_pull_requests(target)
387 
388 
389@activity.defn
390async def dispatch_pr_review(params: DispatchReviewInput) -> None:
391 """Start a PR's determinism review (idempotent per PR + head SHA)."""
392 from temporalio.common import WorkflowIDReusePolicy
393 from temporalio.exceptions import WorkflowAlreadyStartedError
394 
395 from froot.workflow.pr_review_workflow import PrReviewWorkflow
396 from froot.workflow.temporal_client import client, task_queue
397 
398 temporal = await client()
399 try:
400 await temporal.start_workflow(
401 PrReviewWorkflow.run,
402 PrReviewParams(target=params.target, pr=params.pr),
403 id=pr_review_workflow_id(
404 params.target, params.pr.number, params.pr.head_sha
405 ),
406 task_queue=task_queue(),
407 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
408 )
409 except WorkflowAlreadyStartedError:
410 # This (PR, head SHA) already has a review — a no-op, so re-polling
411 # never double-reviews the same commit.
412 return
413 
414 
415@activity.defn
416async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
417 """Check out the PR head and analyze the workflow surface for hazards."""
418 from froot.adapters.github import GitHubForge
419 from froot.adapters.source_tree import load_modules
420 from froot.config.settings import ReviewSettings
421 
422 forge = GitHubForge()
423 with tempfile.TemporaryDirectory() as tmp:
424 workspace = Path(tmp)
425 await forge.checkout_pull_request(
426 params.target, workspace, params.pr.number
427 )
428 # The ASTs and source lines are read into memory here, so the analysis
429 # below is unaffected by the workspace being cleaned up.
430 modules = load_modules(workspace)
431 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
432 
433 
434@activity.defn
435async def adjudicate_frontier(
436 params: AdjudicateInput,
437) -> tuple[FrontierVerdict, ...]:
438 """Run the model over each frontier item; return aligned verdicts."""
439 from froot.adapters.determinism_judge import DeterminismFrontierJudge
440 
441 judge = DeterminismFrontierJudge()
442 verdicts: list[FrontierVerdict] = []
443 for item in params.frontier:
444 verdicts.append(await judge.adjudicate(item))
445 return tuple(verdicts)
446 
447 
448@activity.defn
449async def post_review(params: PostReviewInput) -> str | None:
450 """Upsert the advisory comment (when there are findings); log the ledger."""
451 from froot.adapters.github import GitHubForge
452 
453 body = render_review_comment(params.findings, params.pr.head_sha)
454 url: str | None = None
455 if body is not None:
456 url = await GitHubForge().upsert_issue_comment(
457 params.target, params.pr.number, REVIEW_MARKER, body
458 )
459 _review_log.info(
460 json.dumps(
461 {
462 "event": "loop_outcome",
463 "loop": "determinism-review",
464 "repo": params.target.repo.slug,
465 "pr": params.pr.number,
466 "head_sha": params.pr.head_sha,
467 "findings": len(params.findings),
468 "rules": sorted({f.rule for f in params.findings}),
469 "comment_url": url,
470 }
471 )
472 )
473 return url

How it was verified

make check is green — ruff format + lint, mypy strict, 290 tests at 77% coverage (floor 72%) — and both determinism gates pass. The feature was put through a three-dimension adversarial review (correctness · sandbox/determinism · MHE-honesty) with findings independently verified and folded in:

- a div-by-zero guard in class_earned for a degenerate min_decided; - an aware-datetime coercion at the GitHub boundary (a naive timestamp would have crashed the read-model's now - when subtraction); - the reclaim-only-when-earned honesty fix (above); - a class-accurate footer that names the shadow gate; - an advisory caveat on the gate badge; - the substantive-blocker-first reason ordering (above).

New tests cover the pure policy, the windowing and reclaim logic, the loop-label parse, the settings, the no-op telemetry helper, and the rendered panel + badge.