What changed, and why

froot runs maintenance loops over GitHub repos. Until now the spine learned each loop's specifics by match-ing on a Loop enum: the candidate scan, the judge framing, the reconcile skip, the PR-title verb, the dashboard icon were all per-loop arms scattered across the codebase. Adding a loop meant editing all of them.

This change replaces that with an open registry the spine reads. A LoopSpec carries only the genuinely per-loop seams; the durable chassis — the scan/dispatch machinery, the merge gate, earned autonomy, namespacing, the dashboard — stays loop-agnostic. The three acting loops self-register, and activities, model_judge, and the dashboard look the loop up instead of branching on it.

The shape follows three rules worth holding while you read:

- The gate stays a spine stage, unreachable from any model-filled slot — a LoopSpec exposes only observe, never a merge primitive. - Admission is by placement, not by an LLM call — dead-code is a first-class loop with CI as its oracle and no changelog judge. - **disposition is a field**, not a second loop species — the advisory family folds in behind it next.

Cut #1 is the acting family (dependency-patch, security-patch, dead-code) and is zero behavior change: the existing scan/judge/reconcile/dashboard tests now route through the registry and pass.

The registry: one spec per loop

LoopSpec is a frozen dataclass of the per-loop seams and nothing else. The one callable is observe (signal → candidates); the rest is inert data — disposition, the changelog-judge judge_context (None when a loop does no changelog judging), reconciles, the PR-title title_prefix, the dashboard_icon. The workflow-id segment is deliberately not here — it stays a pure derivation in naming (see the next sections).

The disposition enum, and the spec — six declarative fields, one callable.

src/froot/loops/registry.py · 125 lines
src/froot/loops/registry.py125 lines · Python
⋯ 44 lines hidden (lines 1–44)
1"""The loop registry: froot's open loop catalog, keyed by :class:`Loop`.
2 
3The chassis schedules, dispatches, verifies, and gates; a :class:`LoopSpec`
4fills only the genuinely per-loop seams — the signal→candidate ``observe``
5function, the changelog-judge framing line (when the loop judges changelogs as
6an in-loop effect), and the workflow-id namespace segment. Everything else (the
7scan/dispatch chassis, the merge gate, earned autonomy, the dashboard) is
8loop-agnostic and stays in the spine.
9 
10A loop's *disposition* declares how its work item terminates — COMMIT_OR_REVERT
11(propose a PR; the spine gates the merge) or EMIT_SIGNAL (upsert a decaying
12advisory; no merge, no gate). The commit-vs-emit fork will read that field when
13advisory loops fold in; this cut registers the three acting (COMMIT_OR_REVERT)
14loops, so nothing branches on it yet.
15 
16Loops self-register at import. The registry is populated lazily on first read
17(see :func:`_ensure`) so importing a consumer never forces a particular import
18order, and the per-loop adapter imports stay inside each ``observe`` body.
19"""
20 
21from __future__ import annotations
22 
23from dataclasses import dataclass
24from enum import StrEnum
25from typing import TYPE_CHECKING
26 
27if TYPE_CHECKING:
28 from collections.abc import Awaitable, Callable
29 from pathlib import Path
30 
31 from froot.domain.loop import Loop
32 from froot.domain.repo import TargetRepo
33 from froot.domain.work import WorkItem
34 from froot.ports.protocols import PackageManager
35 
36 # Check out a repo's manifest and select this loop's work items, returning
37 # (considered, items) — ``considered`` is the upstream signal size, so the
38 # scan tick can show how much was seen versus kept.
39 ObserveFn = Callable[
40 [TargetRepo, PackageManager, Path],
41 Awaitable[tuple[int, tuple[WorkItem, ...]]],
42 ]
43 
44 
45class Disposition(StrEnum):
46 """How a loop's work item terminates (the commit-vs-emit fork)."""
47 
48 # Propose a PR; the spine gates the merge (the acting loops).
49 COMMIT_OR_REVERT = "commit-or-revert"
50 # Upsert a decaying advisory comment/label; no merge, no gate (advisory).
51 EMIT_SIGNAL = "emit-signal"
52 
⋯ 26 lines hidden (lines 53–78)
53 
54@dataclass(frozen=True)
55class LoopSpec:
56 """One loop's declarative entry — the per-loop seams, nothing chassis-owned.
57 
58 Attributes:
59 loop: The loop this spec specialises (the registry key).
60 disposition: How its work item terminates (commit vs emit-signal). The
61 commit-vs-emit fork will read this when advisory loops fold in;
62 today every acting loop is ``COMMIT_OR_REVERT``.
63 observe: The signal→candidate seam — the one genuinely per-loop body.
64 title_prefix: The PR-title verb (``deps`` / ``security`` /
65 ``dead-code``) — a per-loop label, not derivable from the loop name.
66 judge_context: The framing line for the in-loop changelog judge, or
67 ``None`` when the loop does no changelog judging (e.g. dead-code,
68 whose judgment is a safe-to-remove veto *at the signal*, inside
69 ``observe``).
70 reconciles: Whether version-supersession reconcile applies — ``True``
71 for bump loops, ``False`` for a loop whose work item carries no
72 version to be overtaken (dead-code removals). The reconcile activity
73 keys on this instead of naming the loop.
74 dashboard_icon: The icon key for this loop's dashboard tab (one of the
75 renderer's ``_ICONS``). Carried here so a new loop's tab is fully
76 presented from its spec — no per-loop arm in the renderer.
77 
78 The workflow-id/branch namespace segment is NOT carried here: it is a pure
79 derivation in :mod:`froot.policy.naming` (empty for ``dependency-patch``,
80 the loop name otherwise) that is already zero-edit for a new loop.
81 """
82 
83 loop: Loop
84 disposition: Disposition
85 observe: ObserveFn
86 title_prefix: str
87 judge_context: str | None = None
88 reconciles: bool = True
89 dashboard_icon: str = "package"
90 
91 
92_LOOPS: dict[Loop, LoopSpec] = {}
93_REGISTERED = False
94 
95 
⋯ 30 lines hidden (lines 96–125)
96def register(spec: LoopSpec) -> None:
97 """Register a loop spec (idempotent on re-import; last write wins)."""
98 _LOOPS[spec.loop] = spec
99 
100 
101def _ensure() -> None:
102 """Import the loop modules once so they self-register (lazy, idempotent)."""
103 global _REGISTERED
104 if _REGISTERED:
105 return
106 _REGISTERED = True
107 # Importing each module runs its module-level register(...) call. Kept here,
108 # not at package import, so the registry has no import-order constraints.
109 from froot.loops import ( # noqa: F401
110 dead_code,
111 dependency_patch,
112 security_patch,
113 )
114 
115 
116def get(loop: Loop) -> LoopSpec:
117 """The registered spec for a loop (raises ``KeyError`` if unregistered)."""
118 _ensure()
119 return _LOOPS[loop]
120 
121 
122def all_specs() -> tuple[LoopSpec, ...]:
123 """Every registered loop spec, in registration order."""
124 _ensure()
125 return tuple(_LOOPS.values())

The registry populates lazily: get() calls _ensure(), which imports the loop modules once so they run their register() calls. That keeps the registry free of import-order constraints and lets each loop keep its heavy adapter imports inside observe, so importing the registry pulls nothing.

register / lazy _ensure / get — a passive dict, no orchestration.

src/froot/loops/registry.py · 125 lines
src/froot/loops/registry.py125 lines · Python
⋯ 95 lines hidden (lines 1–95)
1"""The loop registry: froot's open loop catalog, keyed by :class:`Loop`.
2 
3The chassis schedules, dispatches, verifies, and gates; a :class:`LoopSpec`
4fills only the genuinely per-loop seams — the signal→candidate ``observe``
5function, the changelog-judge framing line (when the loop judges changelogs as
6an in-loop effect), and the workflow-id namespace segment. Everything else (the
7scan/dispatch chassis, the merge gate, earned autonomy, the dashboard) is
8loop-agnostic and stays in the spine.
9 
10A loop's *disposition* declares how its work item terminates — COMMIT_OR_REVERT
11(propose a PR; the spine gates the merge) or EMIT_SIGNAL (upsert a decaying
12advisory; no merge, no gate). The commit-vs-emit fork will read that field when
13advisory loops fold in; this cut registers the three acting (COMMIT_OR_REVERT)
14loops, so nothing branches on it yet.
15 
16Loops self-register at import. The registry is populated lazily on first read
17(see :func:`_ensure`) so importing a consumer never forces a particular import
18order, and the per-loop adapter imports stay inside each ``observe`` body.
19"""
20 
21from __future__ import annotations
22 
23from dataclasses import dataclass
24from enum import StrEnum
25from typing import TYPE_CHECKING
26 
27if TYPE_CHECKING:
28 from collections.abc import Awaitable, Callable
29 from pathlib import Path
30 
31 from froot.domain.loop import Loop
32 from froot.domain.repo import TargetRepo
33 from froot.domain.work import WorkItem
34 from froot.ports.protocols import PackageManager
35 
36 # Check out a repo's manifest and select this loop's work items, returning
37 # (considered, items) — ``considered`` is the upstream signal size, so the
38 # scan tick can show how much was seen versus kept.
39 ObserveFn = Callable[
40 [TargetRepo, PackageManager, Path],
41 Awaitable[tuple[int, tuple[WorkItem, ...]]],
42 ]
43 
44 
45class Disposition(StrEnum):
46 """How a loop's work item terminates (the commit-vs-emit fork)."""
47 
48 # Propose a PR; the spine gates the merge (the acting loops).
49 COMMIT_OR_REVERT = "commit-or-revert"
50 # Upsert a decaying advisory comment/label; no merge, no gate (advisory).
51 EMIT_SIGNAL = "emit-signal"
52 
53 
54@dataclass(frozen=True)
55class LoopSpec:
56 """One loop's declarative entry — the per-loop seams, nothing chassis-owned.
57 
58 Attributes:
59 loop: The loop this spec specialises (the registry key).
60 disposition: How its work item terminates (commit vs emit-signal). The
61 commit-vs-emit fork will read this when advisory loops fold in;
62 today every acting loop is ``COMMIT_OR_REVERT``.
63 observe: The signal→candidate seam — the one genuinely per-loop body.
64 title_prefix: The PR-title verb (``deps`` / ``security`` /
65 ``dead-code``) — a per-loop label, not derivable from the loop name.
66 judge_context: The framing line for the in-loop changelog judge, or
67 ``None`` when the loop does no changelog judging (e.g. dead-code,
68 whose judgment is a safe-to-remove veto *at the signal*, inside
69 ``observe``).
70 reconciles: Whether version-supersession reconcile applies — ``True``
71 for bump loops, ``False`` for a loop whose work item carries no
72 version to be overtaken (dead-code removals). The reconcile activity
73 keys on this instead of naming the loop.
74 dashboard_icon: The icon key for this loop's dashboard tab (one of the
75 renderer's ``_ICONS``). Carried here so a new loop's tab is fully
76 presented from its spec — no per-loop arm in the renderer.
77 
78 The workflow-id/branch namespace segment is NOT carried here: it is a pure
79 derivation in :mod:`froot.policy.naming` (empty for ``dependency-patch``,
80 the loop name otherwise) that is already zero-edit for a new loop.
81 """
82 
83 loop: Loop
84 disposition: Disposition
85 observe: ObserveFn
86 title_prefix: str
87 judge_context: str | None = None
88 reconciles: bool = True
89 dashboard_icon: str = "package"
90 
91 
92_LOOPS: dict[Loop, LoopSpec] = {}
93_REGISTERED = False
94 
95 
96def register(spec: LoopSpec) -> None:
97 """Register a loop spec (idempotent on re-import; last write wins)."""
98 _LOOPS[spec.loop] = spec
99 
100 
101def _ensure() -> None:
102 """Import the loop modules once so they self-register (lazy, idempotent)."""
103 global _REGISTERED
104 if _REGISTERED:
105 return
106 _REGISTERED = True
107 # Importing each module runs its module-level register(...) call. Kept here,
108 # not at package import, so the registry has no import-order constraints.
109 from froot.loops import ( # noqa: F401
110 dead_code,
111 dependency_patch,
112 security_patch,
113 )
114 
115 
116def get(loop: Loop) -> LoopSpec:
117 """The registered spec for a loop (raises ``KeyError`` if unregistered)."""
118 _ensure()
119 return _LOOPS[loop]
120 
121 
122def all_specs() -> tuple[LoopSpec, ...]:
123 """Every registered loop spec, in registration order."""
124 _ensure()
⋯ 1 line hidden (lines 125–125)
125 return tuple(_LOOPS.values())

A loop is a small module

Each loop is one file that defines its observe and registers its spec. dependency-patch is the whole pattern in miniature: read the available upgrades, pick the highest patch. The adapter import stays inside observe (lazy), and the spec is the loop's entire identity.

observe + the registered spec — the title verb and a default icon.

src/froot/loops/dependency_patch.py · 49 lines
src/froot/loops/dependency_patch.py49 lines · Python
⋯ 28 lines hidden (lines 1–28)
1"""The dependency-patch loop: keep dependencies patched (the first loop).
2 
3Signal: the available upgrades the package manager reports. Candidate: the
4highest patch-level target per package (pure selection). Disposition: commit —
5the spine opens a PR and gates the merge.
6"""
7 
8from __future__ import annotations
9 
10from typing import TYPE_CHECKING
11 
12from froot.domain.loop import Loop
13from froot.loops.registry import Disposition, LoopSpec, register
14 
15if TYPE_CHECKING:
16 from pathlib import Path
17 
18 from froot.domain.repo import TargetRepo
19 from froot.domain.work import WorkItem
20 from froot.ports.protocols import PackageManager
21 
22# The framing line for the in-loop changelog judge.
23_JUDGE_CONTEXT = (
24 "This is a patch-level upgrade; weigh whether the notes hide "
25 "any behavioral change behind a 'patch'."
27 
28 
29async def observe(
30 target: TargetRepo,
31 package_manager: PackageManager,
32 manifest_dir: Path,
33) -> tuple[int, tuple[WorkItem, ...]]:
34 """Read the available upgrades and pick the highest patch per package."""
35 from froot.policy.candidates import select_patch_candidates
36 
37 upgrades = await package_manager.list_upgrades(target, manifest_dir)
38 return len(upgrades), select_patch_candidates(upgrades)
39 
40 
41register(
42 LoopSpec(
43 loop=Loop.DEPENDENCY_PATCH,
44 disposition=Disposition.COMMIT_OR_REVERT,
45 observe=observe,
46 title_prefix="deps",
47 judge_context=_JUDGE_CONTEXT,
48 )

dead-code is the interesting one. Its judgment runs at the signal: the safe-to-remove judge vetoes each flagged removal inside observe, so a tool used without an import (pytest, eslint) is dropped before any workflow starts. A judge error drops that removal (fail-safe). Because it does no changelog judging, its spec carries judge_context=None and reconciles=False (a removal has no version to be overtaken).

The veto-at-signal, and a spec that declares it judges elsewhere and skips reconcile.

src/froot/loops/dead_code.py · 84 lines
src/froot/loops/dead_code.py84 lines · Python
⋯ 28 lines hidden (lines 1–28)
1"""The dead-code loop: remove unused dependencies.
2 
3Signal: the unused direct dependencies a static analyzer flags. Judgment: the
4safe-to-remove judge vetoes each *at the signal* (a tool used without an import
5— pytest, eslint — is dropped before a workflow ever starts), so this loop's one
6thin model call lives inside ``observe``, not as an in-loop changelog judge
7(``judge_context`` is therefore ``None``). Candidate: the surviving removals.
8Disposition: commit — the spine opens a PR and gates the merge (CI is oracle).
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING
14 
15from temporalio import activity
16 
17from froot.domain.loop import Loop
18from froot.loops.registry import Disposition, LoopSpec, register
19 
20if TYPE_CHECKING:
21 from pathlib import Path
22 
23 from froot.domain.removal import Removal
24 from froot.domain.repo import TargetRepo
25 from froot.domain.work import WorkItem
26 from froot.ports.protocols import PackageManager
27 
28 
29async def observe(
30 target: TargetRepo,
31 package_manager: PackageManager,
32 manifest_dir: Path,
33) -> tuple[int, tuple[WorkItem, ...]]:
34 """Unused deps flagged, then vetoed safe-to-remove (the veto is the judge).
35 
36 The static analyzer flags every unused direct dependency; the safe-to-remove
37 judge then vetoes each — only a ``clean`` survives to become a PR. A judge
38 error drops that removal (fail-safe: never propose what was not vetted).
39 ``considered`` is the flagged count; the kept count is the survivors, so the
40 scan tick shows how much the veto filtered.
41 """
42 from froot.adapters.model_judge import PydanticAiJudge
43 
44 flagged = await package_manager.list_unused(target, manifest_dir)
45 if not flagged:
46 return 0, ()
47 judge = PydanticAiJudge()
48 kept: list[Removal] = []
49 for removal in flagged:
50 try:
51 verdict = await judge.judge_removal(removal)
52 except Exception as exc:
53 activity.logger.warning(
54 "safe-to-remove judge unavailable for %s; skipping: %r",
55 removal.package,
56 exc,
57 )
58 continue
59 if verdict.kind != "clean":
60 continue
61 # Carry the judge's reasoning into the work item so the PR body explains
62 # why the removal is safe, beside the detector note.
63 enriched = (
64 f"{removal.justification}; {verdict.rationale}"
65 if removal.justification
66 else verdict.rationale
67 )
68 kept.append(removal.model_copy(update={"justification": enriched}))
69 return len(flagged), tuple(kept)
⋯ 2 lines hidden (lines 70–71)
70 
71 
72register(
73 LoopSpec(
74 loop=Loop.DEAD_CODE,
75 disposition=Disposition.COMMIT_OR_REVERT,
76 observe=observe,
77 title_prefix="dead-code",
78 # Dead-code judges at the signal (the veto above), not the changelog.
79 judge_context=None,
80 # A removal carries no version to be overtaken — nothing to reconcile.
81 reconciles=False,
82 dashboard_icon="scissors",
83 )

The spine reads the registry

The per-loop match statements collapse to lookups. Candidate selection — once a three-arm match plus two helpers — is now one line. The changelog-judge framing reads spec.judge_context. The reconcile activity reads spec.reconciles instead of naming dead-code.

_select_candidates: the one genuinely per-loop body now lives in the spec.

src/froot/workflow/activities.py · 825 lines
src/froot/workflow/activities.py825 lines · Python
⋯ 118 lines hidden (lines 1–118)
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.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.determinism import AnalysisResult, FrontierVerdict
30from froot.domain.pull_request import PullRequestDraft, PullRequestRef
31from froot.domain.removal import Removal
32from froot.domain.repo import TargetRepo
33from froot.domain.work import WorkItem
34from froot.policy.a11y_comment import (
35 A11Y_MARKER,
36 render_a11y_comment,
37 should_post,
39from froot.policy.a11y_scan import scan_sources
40from froot.policy.compose import (
41 pr_labels,
42 pull_request_draft,
43 removal_pull_request_draft,
45from froot.policy.determinism import analyze_workflow_surface
46from froot.policy.naming import (
47 branch_name,
48 bump_workflow_id,
49 pr_a11y_review_workflow_id,
50 pr_review_workflow_id,
52from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
53from froot.workflow.types import (
54 AdjudicateA11yInput,
55 AdjudicateInput,
56 AutoMergeInput,
57 CiCheckInput,
58 CloseInput,
59 DispatchA11yInput,
60 DispatchInput,
61 DispatchReviewInput,
62 GateReviewInput,
63 GateSelfTestInput,
64 JudgeInput,
65 MergeInput,
66 OpenPrInput,
67 PostA11yInput,
68 PostReviewInput,
69 PrA11yReviewParams,
70 PrReviewParams,
71 ReconcileInput,
72 RecordInput,
73 ScanCandidatesInput,
75 
76if TYPE_CHECKING:
77 from froot.domain.loop import Loop
78 from froot.ports.protocols import PackageManager
79 
80_log = logging.getLogger("froot.outcome")
81_review_log = logging.getLogger("froot.review")
82_a11y_log = logging.getLogger("froot.a11y")
83_reconcile_log = logging.getLogger("froot.reconcile")
84_scan_log = logging.getLogger("froot.scan")
85_gate_log = logging.getLogger("froot.gate")
86 
87 
88def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
89 """The directory the manifest lives in (a monorepo subdir, or the root)."""
90 return workspace / target.manifest_dir if target.manifest_dir else workspace
91 
92 
93def _draft_for(params: OpenPrInput) -> PullRequestDraft:
94 """Build the PR draft for the work item — bump vs removal compose.
95 
96 The PR-title verb is the loop's, resolved from its registered spec here (the
97 impure boundary) and passed into the pure draft builders.
98 """
99 from froot.loops import registry
100 
101 item = params.candidate
102 title_prefix = registry.get(params.loop).title_prefix
103 match item:
104 case Candidate():
105 return pull_request_draft(
106 params.target,
107 item,
108 params.verdict,
109 params.loop,
110 title_prefix=title_prefix,
111 )
112 case Removal():
113 return removal_pull_request_draft(
114 params.target, item, params.loop, title_prefix=title_prefix
115 )
116 assert_never(item)
117 
118 
119async def _select_candidates(
120 loop: Loop,
121 target: TargetRepo,
122 package_manager: PackageManager,
123 manifest_dir: Path,
124) -> tuple[int, tuple[WorkItem, ...]]:
125 """Gather this loop's signal from the checkout and select its work items.
126 
127 The one genuinely per-loop seam: dependency-patch reads the available
128 upgrades and picks the highest patch; security-patch reads the installed,
129 asks OSV for advisories, and picks the lowest version clearing each;
130 dead-code reads the unused dependencies a static analyzer flags and vetoes
131 each with the safe-to-remove judge. The impure sources are lazy-imported per
132 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
133 dead-code, model-vetoed) selection.
134 
135 Returns ``(considered, items)`` — ``considered`` is the size of the upstream
136 signal (available upgrades / advisories found / unused deps flagged) so the
137 scan can make its selectivity legible (how much was seen versus kept).
138 
139 The one genuinely per-loop body now lives in each loop's spec ``observe``
140 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
141 selection seam needs no per-loop arm.
142 """
143 from froot.loops import registry
144 
145 return await registry.get(loop).observe(
146 target, package_manager, manifest_dir
147 )
⋯ 678 lines hidden (lines 148–825)
148 
149 
150@activity.defn
151async def scan_candidates(
152 params: ScanCandidatesInput,
153) -> tuple[WorkItem, ...]:
154 """Check out the repo and select this loop's candidates.
155 
156 Emits the tick's selectivity — how much upstream signal was considered
157 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
158 and as a structured ``scan_tick`` log, so the signal stage is legible in the
159 run ledger even on a tick that proposes nothing.
160 """
161 from froot.adapters.github import GitHubForge
162 from froot.adapters.registry import package_manager_for
163 from froot.adapters.telemetry import set_span_attributes
164 
165 forge = GitHubForge()
166 package_manager = package_manager_for(params.target.ecosystem)
167 with tempfile.TemporaryDirectory() as tmp:
168 workspace = Path(tmp)
169 await forge.checkout(params.target, workspace)
170 considered, candidates = await _select_candidates(
171 params.loop,
172 params.target,
173 package_manager,
174 _manifest_dir(params.target, workspace),
175 )
176 selected = len(candidates)
177 dropped = max(considered - selected, 0)
178 set_span_attributes(
179 scan_loop=params.loop.value,
180 scan_repo=params.target.repo.slug,
181 scan_considered=considered,
182 scan_selected=selected,
183 scan_dropped=dropped,
184 )
185 _scan_log.info(
186 json.dumps(
187 {
188 "event": "scan_tick",
189 "loop": params.loop.value,
190 "repo": params.target.repo.slug,
191 "considered": considered,
192 "selected": selected,
193 "dropped": dropped,
194 }
195 )
196 )
197 return candidates
198 
199 
200@activity.defn
201async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
202 """Fetch the candidate's changelog and get the model's typed verdict.
203 
204 The model is froot's one thin, non-load-bearing judgment: a clean verdict
205 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
206 erroring must not stall the spine. A judge failure degrades to
207 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
208 dashboard records the verdict as unknown — rather than failing (and then
209 retrying) the activity. Only the model call is guarded; the fetch is already
210 best-effort (returns ``None``, not an exception). The loop selects what the
211 model is asked (clean-patch vs breaking-change-on-a-security-bump).
212 """
213 from froot.adapters.changelog_http import HttpChangelogSource
214 from froot.adapters.model_judge import PydanticAiJudge
215 
216 candidate = params.candidate
217 if isinstance(candidate, Removal):
218 # A removal already cleared the safe-to-remove veto at scan; there is no
219 # changelog to read, so carry that conclusion straight into the PR
220 # framing (the rationale the veto recorded on the work item).
221 return CleanVerdict(
222 rationale=candidate.justification or "unused; safe to remove"
223 )
224 changelog = await HttpChangelogSource().fetch(candidate)
225 if changelog is None:
226 return UnknownVerdict(rationale="No changelog could be fetched.")
227 try:
228 return await PydanticAiJudge().judge(changelog, params.loop)
229 except Exception as exc:
230 activity.logger.warning(
231 "changelog judge unavailable for %s; degrading to unknown: %r",
232 params.candidate.package,
233 exc,
234 )
235 return UnknownVerdict(
236 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
237 )
238 
239 
240@activity.defn
241async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
242 """Independently deep-review a bump at the gate; fail-closed to a hold.
243 
244 The fourth trust leg (§3.7): a second, adversarial model pass over the
245 changelog, run only when a bump is about to auto-merge. ``clean`` approves
246 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
247 missing changelog or a model error returns a non-clean verdict, so an
248 unreviewable bump never merges unattended. This is the opposite disposition
249 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
250 direction is to hold, and a non-clean verdict already means hold.
251 """
252 from froot.adapters.changelog_http import HttpChangelogSource
253 from froot.adapters.model_judge import PydanticAiJudge
254 
255 candidate = params.candidate
256 verdict: ChangelogVerdict
257 if isinstance(candidate, Removal):
258 # The fourth leg for a removal: independently re-judge that it is safe
259 # to remove (the same check the scan veto ran). No changelog to read;
260 # fail-CLOSED to a hold on a model error, like the bump path.
261 try:
262 verdict = await PydanticAiJudge().judge_removal(candidate)
263 except Exception as exc:
264 activity.logger.warning(
265 "safe-to-remove gate review unavailable for %s; holding: %r",
266 candidate.package,
267 exc,
268 )
269 verdict = UnknownVerdict(
270 rationale=(
271 f"Removal reviewer unavailable ({type(exc).__name__})."
272 )
273 )
274 else:
275 changelog = await HttpChangelogSource().fetch(candidate)
276 if changelog is None:
277 verdict = UnknownVerdict(
278 rationale="No changelog to review; holding (fail-closed)."
279 )
280 else:
281 try:
282 verdict = await PydanticAiJudge().gate_review(
283 changelog, params.loop
284 )
285 except Exception as exc:
286 activity.logger.warning(
287 "gate reviewer unavailable for %s; holding: %r",
288 candidate.package,
289 exc,
290 )
291 verdict = UnknownVerdict(
292 rationale=(
293 f"Gate reviewer unavailable ({type(exc).__name__})."
294 )
295 )
296 _gate_log.info(
297 json.dumps(
298 {
299 "event": "gate_review",
300 "loop": params.loop.value,
301 "pr": params.pr.number,
302 "pr_url": params.pr.url,
303 "package": params.candidate.package,
304 "verdict": verdict.kind,
305 "approved": verdict.kind == "clean",
306 }
307 )
308 )
309 return verdict
310 
311 
312@activity.defn
313async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
314 """Rewrite the manifest+lockfile and open (idempotently) the work item's PR.
315 
316 The one place the action differs by work-item kind: a bump regenerates the
317 lockfile at the target version, a removal deletes the dependency. Both are
318 lockfile-only (no install, no scripts); everything around them — the dedup
319 branch, the checkout, the push, the open — is the same chassis.
320 """
321 from froot.adapters.github import GitHubForge
322 from froot.adapters.registry import package_manager_for
323 
324 forge = GitHubForge()
325 package_manager = package_manager_for(params.target.ecosystem)
326 branch = branch_name(params.candidate, params.loop)
327 existing = await forge.find_open_pull_request(params.target, branch)
328 if existing is not None:
329 return existing
330 draft = _draft_for(params)
331 item = params.candidate
332 with tempfile.TemporaryDirectory() as tmp:
333 workspace = Path(tmp)
334 await forge.checkout(params.target, workspace)
335 manifest_dir = _manifest_dir(params.target, workspace)
336 match item:
337 case Candidate():
338 await package_manager.apply_patch_bump(item, manifest_dir)
339 case Removal():
340 await package_manager.remove_dependency(item, manifest_dir)
341 await forge.push_branch(workspace, branch, draft.title)
342 return await forge.open_pull_request(params.target, draft)
343 
344 
345@activity.defn
346async def check_ci(params: CiCheckInput) -> CIStatus:
347 """Read the repo's CI status for the PR's head commit (the oracle)."""
348 from froot.adapters.github import GitHubForge
349 
350 return await GitHubForge().ci_status(params.target, params.head_sha)
351 
352 
353@activity.defn
354async def record_outcome(params: RecordInput) -> None:
355 """Label the PR and log the run telemetry — the signal-update.
356 
357 The labels carry the loop *and* the judgment environment (the judge model)
358 the PR was opened under, so the gate can count only the track record earned
359 under the current environment and reset it when the model changes (§3.7).
360 """
361 from froot.adapters.github import GitHubForge
362 from froot.config.settings import ModelSettings
363 from froot.policy.environment import env_label
364 
365 outcome = params.outcome
366 item = outcome.candidate
367 labels = (
368 *pr_labels(params.loop),
369 env_label(ModelSettings().ollama_model),
370 )
371 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
372 record = {
373 "event": "loop_outcome",
374 "loop": params.loop.value,
375 "repo": params.target.repo.slug,
376 "package": item.package,
377 "changelog": outcome.verdict.kind,
378 "ci": outcome.ci.kind,
379 "ci_passed": outcome.ci_passed,
380 "pr": outcome.pr.number,
381 "pr_url": outcome.pr.url,
382 }
383 match item:
384 case Candidate():
385 record |= {
386 "action": "bump",
387 "from": str(item.current),
388 "to": str(item.target),
389 }
390 case Removal():
391 record |= {"action": "remove", "dev": item.dev}
392 _log.info(json.dumps(record))
393 
394 
395@activity.defn
396async def dispatch_bump(params: DispatchInput) -> None:
397 """Start the bump loop for a candidate (idempotent per bump identity).
398 
399 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
400 the bump's params — so the running workflow never reads config itself and an
401 in-flight bump keeps the value it was dispatched with.
402 """
403 from temporalio.common import WorkflowIDReusePolicy
404 from temporalio.exceptions import WorkflowAlreadyStartedError
405 
406 from froot.config.settings import BehaviorSettings
407 from froot.workflow.bump_workflow import BumpWorkflow
408 from froot.workflow.temporal_client import client, task_queue
409 from froot.workflow.types import BumpParams
410 
411 temporal = await client()
412 try:
413 await temporal.start_workflow(
414 BumpWorkflow.run,
415 BumpParams(
416 target=params.target,
417 candidate=params.candidate,
418 close_on_red=BehaviorSettings().close_on_red,
419 loop=params.loop,
420 ),
421 id=bump_workflow_id(params.target, params.candidate, params.loop),
422 task_queue=task_queue(),
423 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
424 )
425 except WorkflowAlreadyStartedError:
426 # This bump already has a loop (running or completed) — a no-op, so
427 # re-scanning never opens a second PR for the same bump.
428 return
429 
430 
431@activity.defn
432async def auto_merge_eligible(params: AutoMergeInput) -> bool:
433 """Whether this (repo, loop) class has earned the auto-merge grant.
434 
435 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
436 default), so the common case costs nothing. Otherwise it re-derives the
437 class's standing from the live GitHub history — the same triangulated,
438 windowed, environment-scoped computation the dashboard's shadow gate shows
439 (``read_model.earned_now``) — so the acting gate and the advisory panel can
440 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
441 never an auto-merge.
442 """
443 from datetime import UTC, datetime
444 
445 from froot.config.settings import AutonomySettings, ModelSettings
446 from froot.dashboard import github_source, read_model
447 from froot.policy.environment import environment_slug
448 
449 policy = AutonomySettings().policy()
450 repo = params.target.repo.slug
451 if repo not in policy.allowlisted_repos:
452 return False
453 now = datetime.now(UTC)
454 prs, prs_error = await github_source.fetch((repo,))
455 if prs_error is not None:
456 return False # can't confirm the record -> hold, never merge blind
457 outcomes, _ = await github_source.fetch_outcomes(
458 (repo,), prs, now=now, window_days=policy.window_days
459 )
460 return read_model.earned_now(
461 now,
462 prs,
463 outcomes,
464 repo,
465 params.loop,
466 policy,
467 environment_slug(ModelSettings().ollama_model),
468 )
469 
470 
471@activity.defn
472async def merge_pull_request(params: MergeInput) -> None:
473 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
474 
475 Reached only after the pure machine confirmed clean+green and the class
476 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
477 refuses the merge if the head moved since the gate decided.
478 """
479 from froot.adapters.github import GitHubForge
480 
481 await GitHubForge().merge_pull_request(
482 params.target, params.pr.number, head_sha=params.pr.head_sha
483 )
484 _log.info(
485 json.dumps(
486 {
487 "event": "pr_merged",
488 "loop": params.loop.value,
489 "reason": "auto_merge",
490 "repo": params.target.repo.slug,
491 "pr": params.pr.number,
492 "pr_url": params.pr.url,
493 }
494 )
495 )
496 
497 
498@activity.defn
499async def close_pull_request(params: CloseInput) -> None:
500 """Comment why, then close a red bump's PR and delete its branch.
501 
502 The note goes through the idempotent ``upsert_issue_comment`` and the close
503 itself is idempotent, so a retried close edits its comment in place and
504 never double-posts. The bump's record step still runs after this, so the red
505 outcome is logged either way.
506 """
507 from froot.adapters.github import GitHubForge
508 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
509 
510 forge = GitHubForge()
511 body = closed_on_red_comment(params.failing)
512 await forge.upsert_issue_comment(
513 params.target, params.pr.number, CLOSE_MARKER, body
514 )
515 await forge.close_pull_request(
516 params.target, params.pr.number, params.pr.branch
517 )
518 _log.info(
519 json.dumps(
520 {
521 "event": "pr_closed",
522 "loop": params.loop.value,
523 "reason": "ci_red",
524 "repo": params.target.repo.slug,
525 "pr": params.pr.number,
526 "pr_url": params.pr.url,
527 "failing": list(params.failing),
528 }
529 )
530 )
531 
532 
533@activity.defn
534async def reconcile_open_prs(params: ReconcileInput) -> int:
535 """Close this loop's PRs that a newer candidate or the base has overtaken.
536 
537 Self-contained: lists the repo's open PRs, re-derives this loop's live
538 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
539 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
540 loop's PRs to close, and closes each (deleting its branch). Scoped to the
541 loop's own branch namespace, so the two loops never reconcile each other's
542 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
543 """
544 from froot.adapters.github import GitHubForge
545 from froot.adapters.registry import package_manager_for
546 from froot.config.settings import BehaviorSettings
547 from froot.policy.compose import CLOSE_MARKER
548 from froot.policy.reconcile import reconciliations
549 
550 if not BehaviorSettings().reconcile:
551 return 0
552 # Reconcile is version-supersession cleanup, which only bump loops have: a
553 # removal carries no version to be overtaken. A loop that does not reconcile
554 # (dead-code) is skipped rather than re-running its signal (knip + the veto
555 # judge) every tick only to close nothing. (A removal-specific reconcile —
556 # close when no longer unused — is future work.) The trait is on the spec.
557 from froot.loops import registry
558 
559 if not registry.get(params.loop).reconciles:
560 return 0
561 
562 target, loop = params.target, params.loop
563 forge = GitHubForge()
564 package_manager = package_manager_for(target.ecosystem)
565 open_prs = await forge.list_open_pull_requests(target)
566 with tempfile.TemporaryDirectory() as tmp:
567 workspace = Path(tmp)
568 await forge.checkout(target, workspace)
569 _considered, candidates = await _select_candidates(
570 loop, target, package_manager, _manifest_dir(target, workspace)
571 )
572 closures = reconciliations(open_prs, candidates, loop)
573 for closure in closures:
574 await forge.upsert_issue_comment(
575 target, closure.pr.number, CLOSE_MARKER, closure.comment
576 )
577 await forge.close_pull_request(
578 target, closure.pr.number, closure.pr.branch
579 )
580 if closures:
581 _reconcile_log.info(
582 json.dumps(
583 {
584 "event": "reconcile",
585 "loop": loop.value,
586 "repo": target.repo.slug,
587 "closed": len(closures),
588 "prs": [closure.pr.number for closure in closures],
589 }
590 )
591 )
592 return len(closures)
593 
594 
595@activity.defn
596async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
597 """Run the adversarial gate probe against the live policy; alarm on escape.
598 
599 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
600 known-bad class histories a healthy gate must refuse, scored against the
601 policy froot is *actually running* (config and all). Any escape — a bad
602 class the live gate would grant — is logged at ERROR (the alarm) so it
603 surfaces in telemetry the moment config drifts; a clean pass logs a
604 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
605 repo-independent (the ``target``/``loop`` are only the log's context).
606 """
607 from froot.config.settings import AutonomySettings
608 from froot.policy.gate_probe import gate_escapes
609 
610 escaped = gate_escapes(AutonomySettings().policy())
611 record = json.dumps(
612 {
613 "event": "gate_selftest",
614 "loop": params.loop.value,
615 "repo": params.target.repo.slug,
616 "healthy": not escaped,
617 "escaped": list(escaped),
618 }
619 )
620 if escaped:
621 _gate_log.error(record) # an alarm: the gate would trust a bad class
622 else:
623 _gate_log.info(record)
624 return escaped
625 
626 
627# ── The determinism reviewer (the transitive ring) ──────────────────────────
628@activity.defn
629async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
630 """List the repo's open PRs for the determinism reviewer to consider."""
631 from froot.adapters.github import GitHubForge
632 
633 return await GitHubForge().list_open_pull_requests(target)
634 
635 
636@activity.defn
637async def dispatch_pr_review(params: DispatchReviewInput) -> None:
638 """Start a PR's determinism review (idempotent per PR + head SHA)."""
639 from temporalio.common import WorkflowIDReusePolicy
640 from temporalio.exceptions import WorkflowAlreadyStartedError
641 
642 from froot.workflow.pr_review_workflow import PrReviewWorkflow
643 from froot.workflow.temporal_client import client, task_queue
644 
645 temporal = await client()
646 try:
647 await temporal.start_workflow(
648 PrReviewWorkflow.run,
649 PrReviewParams(target=params.target, pr=params.pr),
650 id=pr_review_workflow_id(
651 params.target, params.pr.number, params.pr.head_sha
652 ),
653 task_queue=task_queue(),
654 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
655 )
656 except WorkflowAlreadyStartedError:
657 # This (PR, head SHA) already has a review — a no-op, so re-polling
658 # never double-reviews the same commit.
659 return
660 
661 
662@activity.defn
663async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
664 """Check out the PR head and analyze the workflow surface for hazards."""
665 from froot.adapters.github import GitHubForge
666 from froot.adapters.source_tree import load_modules
667 from froot.config.settings import ReviewSettings
668 
669 forge = GitHubForge()
670 with tempfile.TemporaryDirectory() as tmp:
671 workspace = Path(tmp)
672 await forge.checkout_pull_request(
673 params.target, workspace, params.pr.number
674 )
675 # The ASTs and source lines are read into memory here, so the analysis
676 # below is unaffected by the workspace being cleaned up.
677 modules = load_modules(workspace)
678 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
679 
680 
681@activity.defn
682async def adjudicate_frontier(
683 params: AdjudicateInput,
684) -> tuple[FrontierVerdict, ...]:
685 """Run the model over each frontier item; return aligned verdicts."""
686 from froot.adapters.determinism_judge import DeterminismFrontierJudge
687 
688 judge = DeterminismFrontierJudge()
689 verdicts: list[FrontierVerdict] = []
690 for item in params.frontier:
691 verdicts.append(await judge.adjudicate(item))
692 return tuple(verdicts)
693 
694 
695@activity.defn
696async def post_review(params: PostReviewInput) -> str | None:
697 """Upsert the advisory comment (when there are findings); log the ledger."""
698 from froot.adapters.github import GitHubForge
699 
700 body = render_review_comment(params.findings, params.pr.head_sha)
701 url: str | None = None
702 if body is not None:
703 url = await GitHubForge().upsert_issue_comment(
704 params.target, params.pr.number, REVIEW_MARKER, body
705 )
706 _review_log.info(
707 json.dumps(
708 {
709 "event": "loop_outcome",
710 "loop": "determinism-review",
711 "repo": params.target.repo.slug,
712 "pr": params.pr.number,
713 "head_sha": params.pr.head_sha,
714 "findings": len(params.findings),
715 "rules": sorted({f.rule for f in params.findings}),
716 "comment_url": url,
717 }
718 )
719 )
720 return url
721 
722 
723# ── The a11y reviewer (the source-level design-system ring) ──────────────────
724@activity.defn
725async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
726 """Check out the PR head and scan its changed templates for a11y risks.
727 
728 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
729 in), so the advisory stays bounded and high-signal. The source lines are
730 read into memory in the activity, so the pure scan is unaffected by the
731 workspace being cleaned up.
732 """
733 from froot.adapters.github import GitHubForge
734 from froot.adapters.web_source import load_web_sources
735 
736 forge = GitHubForge()
737 changed = await forge.list_pull_request_files(
738 params.target, params.pr.number
739 )
740 with tempfile.TemporaryDirectory() as tmp:
741 workspace = Path(tmp)
742 await forge.checkout_pull_request(
743 params.target, workspace, params.pr.number
744 )
745 sources = load_web_sources(workspace, changed)
746 candidates = scan_sources(sources)
747 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
748 
749 
750@activity.defn
751async def adjudicate_a11y(
752 params: AdjudicateA11yInput,
753) -> tuple[A11yVerdict, ...]:
754 """Run the model over each flagged candidate; return aligned verdicts."""
755 from froot.adapters.a11y_judge import A11ySourceJudge
756 
757 judge = A11ySourceJudge()
758 verdicts: list[A11yVerdict] = []
759 for candidate in params.candidates:
760 verdicts.append(await judge.adjudicate(candidate))
761 return tuple(verdicts)
762 
763 
764@activity.defn
765async def post_a11y_review(params: PostA11yInput) -> str | None:
766 """Upsert (or clear) the advisory comment; log the ledger row.
767 
768 Posts when there are findings, or when a prior comment must be cleared to
769 "all clear" (true decay — a PR whose gaps were fixed never keeps a stale
770 finding list, the bug the determinism reviewer leaves). A clean PR with
771 no prior comment stays silent, so a clean PR is never spammed.
772 """
773 from froot.adapters.github import GitHubForge
774 
775 forge = GitHubForge()
776 exists = await forge.find_marked_comment(
777 params.target, params.pr.number, A11Y_MARKER
778 )
779 url: str | None = None
780 if should_post(has_findings=bool(params.findings), comment_exists=exists):
781 body = render_a11y_comment(params.findings, params.pr.head_sha)
782 url = await forge.upsert_issue_comment(
783 params.target, params.pr.number, A11Y_MARKER, body
784 )
785 _a11y_log.info(
786 json.dumps(
787 {
788 "event": "loop_outcome",
789 "loop": "a11y-review",
790 "repo": params.target.repo.slug,
791 "pr": params.pr.number,
792 "head_sha": params.pr.head_sha,
793 "findings": len(params.findings),
794 "kinds": sorted({f.kind for f in params.findings}),
795 "comment_url": url,
796 }
797 )
798 )
799 return url
800 
801 
802@activity.defn
803async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
804 """Start a PR's a11y review (idempotent per PR + head SHA)."""
805 from temporalio.common import WorkflowIDReusePolicy
806 from temporalio.exceptions import WorkflowAlreadyStartedError
807 
808 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
809 from froot.workflow.temporal_client import client, task_queue
810 
811 temporal = await client()
812 try:
813 await temporal.start_workflow(
814 PrA11yReviewWorkflow.run,
815 PrA11yReviewParams(target=params.target, pr=params.pr),
816 id=pr_a11y_review_workflow_id(
817 params.target, params.pr.number, params.pr.head_sha
818 ),
819 task_queue=task_queue(),
820 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
821 )
822 except WorkflowAlreadyStartedError:
823 # This (PR, head SHA) already has an a11y review — a no-op, so
824 # re-polling never double-reviews the same commit.
825 return

The changelog framing line, sourced from the loop's spec.

src/froot/adapters/model_judge.py · 218 lines
src/froot/adapters/model_judge.py218 lines · Python
⋯ 93 lines hidden (lines 1–93)
1"""The thin model judgment: how risky is this dependency bump's changelog?
2 
3froot's one model call. A Pydantic AI agent reads the changelog and returns a
4typed :class:`_Assessment`, which :func:`assessment_to_verdict` maps to the
5domain :data:`~froot.domain.changelog.ChangelogVerdict`. The verdict is framing,
6not a gate — the spine proposes the bump regardless — so even a "risky" reading
7just shapes the PR. The *loop* shapes what the model is asked: a patch bump asks
8"is this clean?", a security bump (often a minor/major) asks "what breaks?". The
9model is injected, so tests run it offline with a ``TestModel`` /
10``FunctionModel``; the mapping is a pure function, tested apart from any model.
11"""
12 
13from __future__ import annotations
14 
15from typing import TYPE_CHECKING, Literal, assert_never
16 
17from pydantic import BaseModel, Field
18from pydantic_ai import Agent
19 
20from froot.adapters.model import build_model
21from froot.domain.changelog import (
22 CleanVerdict,
23 RiskyVerdict,
24 UnknownVerdict,
26from froot.domain.loop import Loop
27 
28if TYPE_CHECKING:
29 from pydantic_ai.models import Model
30 
31 from froot.domain.changelog import Changelog, ChangelogVerdict
32 from froot.domain.removal import Removal
33 
34_SYSTEM_PROMPT = (
35 "You assess the changelog of a dependency upgrade for a code-maintenance "
36 "bot. The bot proposes the bump either way; your job is only to frame the "
37 "risk for the human reviewer.\n"
38 "Return one verdict:\n"
39 "- clean: the notes describe only fixes / docs / internal changes with no "
40 "behavioral or API impact.\n"
41 "- risky: the notes hint at behavior change, deprecations, or anything a "
42 "reviewer should look at closely; list the concerns.\n"
43 "- unknown: the notes are empty or uninformative.\n"
44 "Quote-or-omit: base 'risky' concerns on what the text actually says; do "
45 "not speculate. Keep the rationale to one or two sentences."
47 
48# The gate reviewer's stance is adversarial and asymmetric: this bump is about
49# to merge with NO human in the loop, so the burden is on the changelog to prove
50# it is safe, not on the reviewer to prove it is dangerous. The same _Assessment
51# shape, read as approve/hold (clean = approve).
52_GATE_SYSTEM_PROMPT = (
53 "You are the LAST line of defense before a dependency upgrade auto-merges "
54 "with no human review. Re-read the changelog adversarially and decide "
55 "whether it is safe to merge unattended.\n"
56 "Return one verdict:\n"
57 "- clean: ONLY if the notes clearly describe nothing beyond fixes / docs / "
58 "internal changes — no behavioral change, no API change, no deprecation, "
59 "no ambiguity. This approves the merge.\n"
60 "- risky: any hint of behavior change, removal, deprecation, or surface a "
61 "reviewer should see; list the concerns. This holds the PR.\n"
62 "- unknown: the notes are empty, uninformative, or you are unsure. This "
63 "holds the PR.\n"
64 "The burden is on the changelog to prove safety: when in doubt, do NOT "
65 "return clean. Base concerns on what the text says; keep it to one or two "
66 "sentences."
68 
69 
70# The dead-code loop's thin judgment: a static analyzer flagged a dependency as
71# never imported, but "not imported" is not "unused" — CLI/test/build tools
72# (pytest, eslint, tsc), framework plugins, type-only/peer deps, and dynamically
73# loaded packages are all used without an import. This judge vetoes those so the
74# loop never opens a noisy "remove pytest" PR; CI is still the final oracle, but
75# a wrong removal wastes a human's time, so the burden is on "safe".
76_REMOVAL_SYSTEM_PROMPT = (
77 "You decide whether an UNUSED dependency is safe to remove from a project, "
78 "for a code-maintenance bot. A static analyzer flagged it as never "
79 "imported in the source. But 'not imported' does not always mean "
80 "'unused'.\n"
81 "Return one verdict:\n"
82 "- clean: a normal library that, if genuinely never imported, the build no "
83 "longer needs — safe to remove. This proposes the removal.\n"
84 "- risky: plausibly used WITHOUT an import — a CLI/test/build tool run as "
85 "a command (e.g. pytest, eslint, prettier, tsc, ruff), a framework or "
86 "bundler plugin, a type-only or peer dependency, or something loaded "
87 "dynamically or via config. Say which. This holds it back (no PR).\n"
88 "- unknown: you cannot tell. This holds it back.\n"
89 "CI is the final check, but a wrong removal wastes a human's time, so when "
90 "in doubt do NOT return clean. Keep the rationale to one or two sentences."
92 
93 
94def _loop_context(loop: Loop) -> str:
95 """The framing line telling the model what kind of bump it is judging.
96 
97 Sourced from the loop's registered spec (see :mod:`froot.loops`). Only
98 changelog-judging loops reach this; a loop without a changelog judge
99 (dead-code, whose judgment is a signal-time safe-to-remove veto) has a
100 ``judge_context`` of ``None`` and never gets here — the empty fallback just
101 keeps the call total.
102 """
103 from froot.loops import registry
104 
105 return registry.get(loop).judge_context or ""
106 
⋯ 112 lines hidden (lines 107–218)
107 
108class _Assessment(BaseModel):
109 """The model's structured output, mapped to a domain verdict."""
110 
111 verdict: Literal["clean", "risky", "unknown"]
112 rationale: str
113 concerns: list[str] = Field(default_factory=list)
114 
115 
116def assessment_to_verdict(assessment: _Assessment) -> ChangelogVerdict:
117 """Map the model's structured assessment to a domain verdict (pure)."""
118 match assessment.verdict:
119 case "clean":
120 return CleanVerdict(rationale=assessment.rationale)
121 case "risky":
122 return RiskyVerdict(
123 rationale=assessment.rationale,
124 concerns=tuple(assessment.concerns),
125 )
126 case "unknown":
127 return UnknownVerdict(rationale=assessment.rationale)
128 assert_never(assessment.verdict)
129 
130 
131def _gate_model() -> Model:
132 """Build the gate reviewer's model: the override, else the judge model."""
133 from froot.config.settings import ModelSettings
134 
135 return build_model(model_name=ModelSettings().gate_review_model or None)
136 
137 
138def _changelog_prompt(changelog: Changelog, loop: Loop) -> str:
139 """The shared user prompt: the bump's context and its changelog text."""
140 return (
141 f"{_loop_context(loop)}\n"
142 f"Package: {changelog.package}\n"
143 f"Target version: {changelog.version}\n"
144 f"Changelog:\n{changelog.text}"
145 )
146 
147 
148def _removal_prompt(removal: Removal) -> str:
149 """The user prompt for the safe-to-remove judge: the flagged dependency."""
150 declared = "a dev dependency" if removal.dev else "a runtime dependency"
151 return (
152 f"Package: {removal.package}\n"
153 f"Ecosystem: {removal.ecosystem.value}\n"
154 f"Declared as: {declared}\n"
155 f"Detector note: {removal.justification or 'flagged as unused'}\n"
156 "Is this safe to remove?"
157 )
158 
159 
160class PydanticAiJudge:
161 """A :class:`~froot.ports.protocols.ModelJudge` backed by Pydantic AI.
162 
163 Two agents share the structured ``_Assessment`` output but differ in stance:
164 the neutral framing judge (:meth:`judge`) and the adversarial gate reviewer
165 (:meth:`gate_review`), each its own model so a steward can make the deep
166 review independent in capability, not just prompt.
167 """
168 
169 def __init__(
170 self, model: Model | None = None, gate_model: Model | None = None
171 ) -> None:
172 """Build both agents.
173 
174 ``model`` defaults to the configured Ollama; ``gate_model`` to the
175 gate-review override, else ``model``, else the gate-review model.
176 """
177 self._agent: Agent[None, _Assessment] = Agent(
178 model or build_model(),
179 output_type=_Assessment,
180 system_prompt=_SYSTEM_PROMPT,
181 )
182 self._gate_agent: Agent[None, _Assessment] = Agent(
183 gate_model or model or _gate_model(),
184 output_type=_Assessment,
185 system_prompt=_GATE_SYSTEM_PROMPT,
186 )
187 # The dead-code loop's safe-to-remove judge; the neutral model, its own
188 # prompt. It is a veto (clean = propose), so it carries real weight —
189 # but CI remains the oracle.
190 self._removal_agent: Agent[None, _Assessment] = Agent(
191 model or build_model(),
192 output_type=_Assessment,
193 system_prompt=_REMOVAL_SYSTEM_PROMPT,
194 )
195 
196 async def judge(
197 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
198 ) -> ChangelogVerdict:
199 """Assess a changelog into a verdict, framed by the loop."""
200 result = await self._agent.run(_changelog_prompt(changelog, loop))
201 return assessment_to_verdict(result.output)
202 
203 async def gate_review(
204 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
205 ) -> ChangelogVerdict:
206 """Adversarially re-review a bump at the gate (clean = approve)."""
207 result = await self._gate_agent.run(_changelog_prompt(changelog, loop))
208 return assessment_to_verdict(result.output)
209 
210 async def judge_removal(self, removal: Removal) -> ChangelogVerdict:
211 """Assess whether an unused dependency is safe to remove (clean = yes).
212 
213 The dead-code loop's veto: ``clean`` lets the removal become a PR; any
214 other verdict holds it back. Same ``_Assessment`` shape as the changelog
215 judge, a different prompt.
216 """
217 result = await self._removal_agent.run(_removal_prompt(removal))
218 return assessment_to_verdict(result.output)

Reconcile keys on the trait, not the loop name.

src/froot/workflow/activities.py · 825 lines
src/froot/workflow/activities.py825 lines · Python
⋯ 552 lines hidden (lines 1–552)
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.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.determinism import AnalysisResult, FrontierVerdict
30from froot.domain.pull_request import PullRequestDraft, PullRequestRef
31from froot.domain.removal import Removal
32from froot.domain.repo import TargetRepo
33from froot.domain.work import WorkItem
34from froot.policy.a11y_comment import (
35 A11Y_MARKER,
36 render_a11y_comment,
37 should_post,
39from froot.policy.a11y_scan import scan_sources
40from froot.policy.compose import (
41 pr_labels,
42 pull_request_draft,
43 removal_pull_request_draft,
45from froot.policy.determinism import analyze_workflow_surface
46from froot.policy.naming import (
47 branch_name,
48 bump_workflow_id,
49 pr_a11y_review_workflow_id,
50 pr_review_workflow_id,
52from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
53from froot.workflow.types import (
54 AdjudicateA11yInput,
55 AdjudicateInput,
56 AutoMergeInput,
57 CiCheckInput,
58 CloseInput,
59 DispatchA11yInput,
60 DispatchInput,
61 DispatchReviewInput,
62 GateReviewInput,
63 GateSelfTestInput,
64 JudgeInput,
65 MergeInput,
66 OpenPrInput,
67 PostA11yInput,
68 PostReviewInput,
69 PrA11yReviewParams,
70 PrReviewParams,
71 ReconcileInput,
72 RecordInput,
73 ScanCandidatesInput,
75 
76if TYPE_CHECKING:
77 from froot.domain.loop import Loop
78 from froot.ports.protocols import PackageManager
79 
80_log = logging.getLogger("froot.outcome")
81_review_log = logging.getLogger("froot.review")
82_a11y_log = logging.getLogger("froot.a11y")
83_reconcile_log = logging.getLogger("froot.reconcile")
84_scan_log = logging.getLogger("froot.scan")
85_gate_log = logging.getLogger("froot.gate")
86 
87 
88def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
89 """The directory the manifest lives in (a monorepo subdir, or the root)."""
90 return workspace / target.manifest_dir if target.manifest_dir else workspace
91 
92 
93def _draft_for(params: OpenPrInput) -> PullRequestDraft:
94 """Build the PR draft for the work item — bump vs removal compose.
95 
96 The PR-title verb is the loop's, resolved from its registered spec here (the
97 impure boundary) and passed into the pure draft builders.
98 """
99 from froot.loops import registry
100 
101 item = params.candidate
102 title_prefix = registry.get(params.loop).title_prefix
103 match item:
104 case Candidate():
105 return pull_request_draft(
106 params.target,
107 item,
108 params.verdict,
109 params.loop,
110 title_prefix=title_prefix,
111 )
112 case Removal():
113 return removal_pull_request_draft(
114 params.target, item, params.loop, title_prefix=title_prefix
115 )
116 assert_never(item)
117 
118 
119async def _select_candidates(
120 loop: Loop,
121 target: TargetRepo,
122 package_manager: PackageManager,
123 manifest_dir: Path,
124) -> tuple[int, tuple[WorkItem, ...]]:
125 """Gather this loop's signal from the checkout and select its work items.
126 
127 The one genuinely per-loop seam: dependency-patch reads the available
128 upgrades and picks the highest patch; security-patch reads the installed,
129 asks OSV for advisories, and picks the lowest version clearing each;
130 dead-code reads the unused dependencies a static analyzer flags and vetoes
131 each with the safe-to-remove judge. The impure sources are lazy-imported per
132 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
133 dead-code, model-vetoed) selection.
134 
135 Returns ``(considered, items)`` — ``considered`` is the size of the upstream
136 signal (available upgrades / advisories found / unused deps flagged) so the
137 scan can make its selectivity legible (how much was seen versus kept).
138 
139 The one genuinely per-loop body now lives in each loop's spec ``observe``
140 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
141 selection seam needs no per-loop arm.
142 """
143 from froot.loops import registry
144 
145 return await registry.get(loop).observe(
146 target, package_manager, manifest_dir
147 )
148 
149 
150@activity.defn
151async def scan_candidates(
152 params: ScanCandidatesInput,
153) -> tuple[WorkItem, ...]:
154 """Check out the repo and select this loop's candidates.
155 
156 Emits the tick's selectivity — how much upstream signal was considered
157 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
158 and as a structured ``scan_tick`` log, so the signal stage is legible in the
159 run ledger even on a tick that proposes nothing.
160 """
161 from froot.adapters.github import GitHubForge
162 from froot.adapters.registry import package_manager_for
163 from froot.adapters.telemetry import set_span_attributes
164 
165 forge = GitHubForge()
166 package_manager = package_manager_for(params.target.ecosystem)
167 with tempfile.TemporaryDirectory() as tmp:
168 workspace = Path(tmp)
169 await forge.checkout(params.target, workspace)
170 considered, candidates = await _select_candidates(
171 params.loop,
172 params.target,
173 package_manager,
174 _manifest_dir(params.target, workspace),
175 )
176 selected = len(candidates)
177 dropped = max(considered - selected, 0)
178 set_span_attributes(
179 scan_loop=params.loop.value,
180 scan_repo=params.target.repo.slug,
181 scan_considered=considered,
182 scan_selected=selected,
183 scan_dropped=dropped,
184 )
185 _scan_log.info(
186 json.dumps(
187 {
188 "event": "scan_tick",
189 "loop": params.loop.value,
190 "repo": params.target.repo.slug,
191 "considered": considered,
192 "selected": selected,
193 "dropped": dropped,
194 }
195 )
196 )
197 return candidates
198 
199 
200@activity.defn
201async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
202 """Fetch the candidate's changelog and get the model's typed verdict.
203 
204 The model is froot's one thin, non-load-bearing judgment: a clean verdict
205 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
206 erroring must not stall the spine. A judge failure degrades to
207 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
208 dashboard records the verdict as unknown — rather than failing (and then
209 retrying) the activity. Only the model call is guarded; the fetch is already
210 best-effort (returns ``None``, not an exception). The loop selects what the
211 model is asked (clean-patch vs breaking-change-on-a-security-bump).
212 """
213 from froot.adapters.changelog_http import HttpChangelogSource
214 from froot.adapters.model_judge import PydanticAiJudge
215 
216 candidate = params.candidate
217 if isinstance(candidate, Removal):
218 # A removal already cleared the safe-to-remove veto at scan; there is no
219 # changelog to read, so carry that conclusion straight into the PR
220 # framing (the rationale the veto recorded on the work item).
221 return CleanVerdict(
222 rationale=candidate.justification or "unused; safe to remove"
223 )
224 changelog = await HttpChangelogSource().fetch(candidate)
225 if changelog is None:
226 return UnknownVerdict(rationale="No changelog could be fetched.")
227 try:
228 return await PydanticAiJudge().judge(changelog, params.loop)
229 except Exception as exc:
230 activity.logger.warning(
231 "changelog judge unavailable for %s; degrading to unknown: %r",
232 params.candidate.package,
233 exc,
234 )
235 return UnknownVerdict(
236 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
237 )
238 
239 
240@activity.defn
241async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
242 """Independently deep-review a bump at the gate; fail-closed to a hold.
243 
244 The fourth trust leg (§3.7): a second, adversarial model pass over the
245 changelog, run only when a bump is about to auto-merge. ``clean`` approves
246 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
247 missing changelog or a model error returns a non-clean verdict, so an
248 unreviewable bump never merges unattended. This is the opposite disposition
249 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
250 direction is to hold, and a non-clean verdict already means hold.
251 """
252 from froot.adapters.changelog_http import HttpChangelogSource
253 from froot.adapters.model_judge import PydanticAiJudge
254 
255 candidate = params.candidate
256 verdict: ChangelogVerdict
257 if isinstance(candidate, Removal):
258 # The fourth leg for a removal: independently re-judge that it is safe
259 # to remove (the same check the scan veto ran). No changelog to read;
260 # fail-CLOSED to a hold on a model error, like the bump path.
261 try:
262 verdict = await PydanticAiJudge().judge_removal(candidate)
263 except Exception as exc:
264 activity.logger.warning(
265 "safe-to-remove gate review unavailable for %s; holding: %r",
266 candidate.package,
267 exc,
268 )
269 verdict = UnknownVerdict(
270 rationale=(
271 f"Removal reviewer unavailable ({type(exc).__name__})."
272 )
273 )
274 else:
275 changelog = await HttpChangelogSource().fetch(candidate)
276 if changelog is None:
277 verdict = UnknownVerdict(
278 rationale="No changelog to review; holding (fail-closed)."
279 )
280 else:
281 try:
282 verdict = await PydanticAiJudge().gate_review(
283 changelog, params.loop
284 )
285 except Exception as exc:
286 activity.logger.warning(
287 "gate reviewer unavailable for %s; holding: %r",
288 candidate.package,
289 exc,
290 )
291 verdict = UnknownVerdict(
292 rationale=(
293 f"Gate reviewer unavailable ({type(exc).__name__})."
294 )
295 )
296 _gate_log.info(
297 json.dumps(
298 {
299 "event": "gate_review",
300 "loop": params.loop.value,
301 "pr": params.pr.number,
302 "pr_url": params.pr.url,
303 "package": params.candidate.package,
304 "verdict": verdict.kind,
305 "approved": verdict.kind == "clean",
306 }
307 )
308 )
309 return verdict
310 
311 
312@activity.defn
313async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
314 """Rewrite the manifest+lockfile and open (idempotently) the work item's PR.
315 
316 The one place the action differs by work-item kind: a bump regenerates the
317 lockfile at the target version, a removal deletes the dependency. Both are
318 lockfile-only (no install, no scripts); everything around them — the dedup
319 branch, the checkout, the push, the open — is the same chassis.
320 """
321 from froot.adapters.github import GitHubForge
322 from froot.adapters.registry import package_manager_for
323 
324 forge = GitHubForge()
325 package_manager = package_manager_for(params.target.ecosystem)
326 branch = branch_name(params.candidate, params.loop)
327 existing = await forge.find_open_pull_request(params.target, branch)
328 if existing is not None:
329 return existing
330 draft = _draft_for(params)
331 item = params.candidate
332 with tempfile.TemporaryDirectory() as tmp:
333 workspace = Path(tmp)
334 await forge.checkout(params.target, workspace)
335 manifest_dir = _manifest_dir(params.target, workspace)
336 match item:
337 case Candidate():
338 await package_manager.apply_patch_bump(item, manifest_dir)
339 case Removal():
340 await package_manager.remove_dependency(item, manifest_dir)
341 await forge.push_branch(workspace, branch, draft.title)
342 return await forge.open_pull_request(params.target, draft)
343 
344 
345@activity.defn
346async def check_ci(params: CiCheckInput) -> CIStatus:
347 """Read the repo's CI status for the PR's head commit (the oracle)."""
348 from froot.adapters.github import GitHubForge
349 
350 return await GitHubForge().ci_status(params.target, params.head_sha)
351 
352 
353@activity.defn
354async def record_outcome(params: RecordInput) -> None:
355 """Label the PR and log the run telemetry — the signal-update.
356 
357 The labels carry the loop *and* the judgment environment (the judge model)
358 the PR was opened under, so the gate can count only the track record earned
359 under the current environment and reset it when the model changes (§3.7).
360 """
361 from froot.adapters.github import GitHubForge
362 from froot.config.settings import ModelSettings
363 from froot.policy.environment import env_label
364 
365 outcome = params.outcome
366 item = outcome.candidate
367 labels = (
368 *pr_labels(params.loop),
369 env_label(ModelSettings().ollama_model),
370 )
371 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
372 record = {
373 "event": "loop_outcome",
374 "loop": params.loop.value,
375 "repo": params.target.repo.slug,
376 "package": item.package,
377 "changelog": outcome.verdict.kind,
378 "ci": outcome.ci.kind,
379 "ci_passed": outcome.ci_passed,
380 "pr": outcome.pr.number,
381 "pr_url": outcome.pr.url,
382 }
383 match item:
384 case Candidate():
385 record |= {
386 "action": "bump",
387 "from": str(item.current),
388 "to": str(item.target),
389 }
390 case Removal():
391 record |= {"action": "remove", "dev": item.dev}
392 _log.info(json.dumps(record))
393 
394 
395@activity.defn
396async def dispatch_bump(params: DispatchInput) -> None:
397 """Start the bump loop for a candidate (idempotent per bump identity).
398 
399 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
400 the bump's params — so the running workflow never reads config itself and an
401 in-flight bump keeps the value it was dispatched with.
402 """
403 from temporalio.common import WorkflowIDReusePolicy
404 from temporalio.exceptions import WorkflowAlreadyStartedError
405 
406 from froot.config.settings import BehaviorSettings
407 from froot.workflow.bump_workflow import BumpWorkflow
408 from froot.workflow.temporal_client import client, task_queue
409 from froot.workflow.types import BumpParams
410 
411 temporal = await client()
412 try:
413 await temporal.start_workflow(
414 BumpWorkflow.run,
415 BumpParams(
416 target=params.target,
417 candidate=params.candidate,
418 close_on_red=BehaviorSettings().close_on_red,
419 loop=params.loop,
420 ),
421 id=bump_workflow_id(params.target, params.candidate, params.loop),
422 task_queue=task_queue(),
423 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
424 )
425 except WorkflowAlreadyStartedError:
426 # This bump already has a loop (running or completed) — a no-op, so
427 # re-scanning never opens a second PR for the same bump.
428 return
429 
430 
431@activity.defn
432async def auto_merge_eligible(params: AutoMergeInput) -> bool:
433 """Whether this (repo, loop) class has earned the auto-merge grant.
434 
435 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
436 default), so the common case costs nothing. Otherwise it re-derives the
437 class's standing from the live GitHub history — the same triangulated,
438 windowed, environment-scoped computation the dashboard's shadow gate shows
439 (``read_model.earned_now``) — so the acting gate and the advisory panel can
440 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
441 never an auto-merge.
442 """
443 from datetime import UTC, datetime
444 
445 from froot.config.settings import AutonomySettings, ModelSettings
446 from froot.dashboard import github_source, read_model
447 from froot.policy.environment import environment_slug
448 
449 policy = AutonomySettings().policy()
450 repo = params.target.repo.slug
451 if repo not in policy.allowlisted_repos:
452 return False
453 now = datetime.now(UTC)
454 prs, prs_error = await github_source.fetch((repo,))
455 if prs_error is not None:
456 return False # can't confirm the record -> hold, never merge blind
457 outcomes, _ = await github_source.fetch_outcomes(
458 (repo,), prs, now=now, window_days=policy.window_days
459 )
460 return read_model.earned_now(
461 now,
462 prs,
463 outcomes,
464 repo,
465 params.loop,
466 policy,
467 environment_slug(ModelSettings().ollama_model),
468 )
469 
470 
471@activity.defn
472async def merge_pull_request(params: MergeInput) -> None:
473 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
474 
475 Reached only after the pure machine confirmed clean+green and the class
476 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
477 refuses the merge if the head moved since the gate decided.
478 """
479 from froot.adapters.github import GitHubForge
480 
481 await GitHubForge().merge_pull_request(
482 params.target, params.pr.number, head_sha=params.pr.head_sha
483 )
484 _log.info(
485 json.dumps(
486 {
487 "event": "pr_merged",
488 "loop": params.loop.value,
489 "reason": "auto_merge",
490 "repo": params.target.repo.slug,
491 "pr": params.pr.number,
492 "pr_url": params.pr.url,
493 }
494 )
495 )
496 
497 
498@activity.defn
499async def close_pull_request(params: CloseInput) -> None:
500 """Comment why, then close a red bump's PR and delete its branch.
501 
502 The note goes through the idempotent ``upsert_issue_comment`` and the close
503 itself is idempotent, so a retried close edits its comment in place and
504 never double-posts. The bump's record step still runs after this, so the red
505 outcome is logged either way.
506 """
507 from froot.adapters.github import GitHubForge
508 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
509 
510 forge = GitHubForge()
511 body = closed_on_red_comment(params.failing)
512 await forge.upsert_issue_comment(
513 params.target, params.pr.number, CLOSE_MARKER, body
514 )
515 await forge.close_pull_request(
516 params.target, params.pr.number, params.pr.branch
517 )
518 _log.info(
519 json.dumps(
520 {
521 "event": "pr_closed",
522 "loop": params.loop.value,
523 "reason": "ci_red",
524 "repo": params.target.repo.slug,
525 "pr": params.pr.number,
526 "pr_url": params.pr.url,
527 "failing": list(params.failing),
528 }
529 )
530 )
531 
532 
533@activity.defn
534async def reconcile_open_prs(params: ReconcileInput) -> int:
535 """Close this loop's PRs that a newer candidate or the base has overtaken.
536 
537 Self-contained: lists the repo's open PRs, re-derives this loop's live
538 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
539 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
540 loop's PRs to close, and closes each (deleting its branch). Scoped to the
541 loop's own branch namespace, so the two loops never reconcile each other's
542 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
543 """
544 from froot.adapters.github import GitHubForge
545 from froot.adapters.registry import package_manager_for
546 from froot.config.settings import BehaviorSettings
547 from froot.policy.compose import CLOSE_MARKER
548 from froot.policy.reconcile import reconciliations
549 
550 if not BehaviorSettings().reconcile:
551 return 0
552 # Reconcile is version-supersession cleanup, which only bump loops have: a
553 # removal carries no version to be overtaken. A loop that does not reconcile
554 # (dead-code) is skipped rather than re-running its signal (knip + the veto
555 # judge) every tick only to close nothing. (A removal-specific reconcile —
556 # close when no longer unused — is future work.) The trait is on the spec.
557 from froot.loops import registry
558 
559 if not registry.get(params.loop).reconciles:
560 return 0
⋯ 265 lines hidden (lines 561–825)
561 
562 target, loop = params.target, params.loop
563 forge = GitHubForge()
564 package_manager = package_manager_for(target.ecosystem)
565 open_prs = await forge.list_open_pull_requests(target)
566 with tempfile.TemporaryDirectory() as tmp:
567 workspace = Path(tmp)
568 await forge.checkout(target, workspace)
569 _considered, candidates = await _select_candidates(
570 loop, target, package_manager, _manifest_dir(target, workspace)
571 )
572 closures = reconciliations(open_prs, candidates, loop)
573 for closure in closures:
574 await forge.upsert_issue_comment(
575 target, closure.pr.number, CLOSE_MARKER, closure.comment
576 )
577 await forge.close_pull_request(
578 target, closure.pr.number, closure.pr.branch
579 )
580 if closures:
581 _reconcile_log.info(
582 json.dumps(
583 {
584 "event": "reconcile",
585 "loop": loop.value,
586 "repo": target.repo.slug,
587 "closed": len(closures),
588 "prs": [closure.pr.number for closure in closures],
589 }
590 )
591 )
592 return len(closures)
593 
594 
595@activity.defn
596async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
597 """Run the adversarial gate probe against the live policy; alarm on escape.
598 
599 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
600 known-bad class histories a healthy gate must refuse, scored against the
601 policy froot is *actually running* (config and all). Any escape — a bad
602 class the live gate would grant — is logged at ERROR (the alarm) so it
603 surfaces in telemetry the moment config drifts; a clean pass logs a
604 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
605 repo-independent (the ``target``/``loop`` are only the log's context).
606 """
607 from froot.config.settings import AutonomySettings
608 from froot.policy.gate_probe import gate_escapes
609 
610 escaped = gate_escapes(AutonomySettings().policy())
611 record = json.dumps(
612 {
613 "event": "gate_selftest",
614 "loop": params.loop.value,
615 "repo": params.target.repo.slug,
616 "healthy": not escaped,
617 "escaped": list(escaped),
618 }
619 )
620 if escaped:
621 _gate_log.error(record) # an alarm: the gate would trust a bad class
622 else:
623 _gate_log.info(record)
624 return escaped
625 
626 
627# ── The determinism reviewer (the transitive ring) ──────────────────────────
628@activity.defn
629async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
630 """List the repo's open PRs for the determinism reviewer to consider."""
631 from froot.adapters.github import GitHubForge
632 
633 return await GitHubForge().list_open_pull_requests(target)
634 
635 
636@activity.defn
637async def dispatch_pr_review(params: DispatchReviewInput) -> None:
638 """Start a PR's determinism review (idempotent per PR + head SHA)."""
639 from temporalio.common import WorkflowIDReusePolicy
640 from temporalio.exceptions import WorkflowAlreadyStartedError
641 
642 from froot.workflow.pr_review_workflow import PrReviewWorkflow
643 from froot.workflow.temporal_client import client, task_queue
644 
645 temporal = await client()
646 try:
647 await temporal.start_workflow(
648 PrReviewWorkflow.run,
649 PrReviewParams(target=params.target, pr=params.pr),
650 id=pr_review_workflow_id(
651 params.target, params.pr.number, params.pr.head_sha
652 ),
653 task_queue=task_queue(),
654 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
655 )
656 except WorkflowAlreadyStartedError:
657 # This (PR, head SHA) already has a review — a no-op, so re-polling
658 # never double-reviews the same commit.
659 return
660 
661 
662@activity.defn
663async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
664 """Check out the PR head and analyze the workflow surface for hazards."""
665 from froot.adapters.github import GitHubForge
666 from froot.adapters.source_tree import load_modules
667 from froot.config.settings import ReviewSettings
668 
669 forge = GitHubForge()
670 with tempfile.TemporaryDirectory() as tmp:
671 workspace = Path(tmp)
672 await forge.checkout_pull_request(
673 params.target, workspace, params.pr.number
674 )
675 # The ASTs and source lines are read into memory here, so the analysis
676 # below is unaffected by the workspace being cleaned up.
677 modules = load_modules(workspace)
678 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
679 
680 
681@activity.defn
682async def adjudicate_frontier(
683 params: AdjudicateInput,
684) -> tuple[FrontierVerdict, ...]:
685 """Run the model over each frontier item; return aligned verdicts."""
686 from froot.adapters.determinism_judge import DeterminismFrontierJudge
687 
688 judge = DeterminismFrontierJudge()
689 verdicts: list[FrontierVerdict] = []
690 for item in params.frontier:
691 verdicts.append(await judge.adjudicate(item))
692 return tuple(verdicts)
693 
694 
695@activity.defn
696async def post_review(params: PostReviewInput) -> str | None:
697 """Upsert the advisory comment (when there are findings); log the ledger."""
698 from froot.adapters.github import GitHubForge
699 
700 body = render_review_comment(params.findings, params.pr.head_sha)
701 url: str | None = None
702 if body is not None:
703 url = await GitHubForge().upsert_issue_comment(
704 params.target, params.pr.number, REVIEW_MARKER, body
705 )
706 _review_log.info(
707 json.dumps(
708 {
709 "event": "loop_outcome",
710 "loop": "determinism-review",
711 "repo": params.target.repo.slug,
712 "pr": params.pr.number,
713 "head_sha": params.pr.head_sha,
714 "findings": len(params.findings),
715 "rules": sorted({f.rule for f in params.findings}),
716 "comment_url": url,
717 }
718 )
719 )
720 return url
721 
722 
723# ── The a11y reviewer (the source-level design-system ring) ──────────────────
724@activity.defn
725async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
726 """Check out the PR head and scan its changed templates for a11y risks.
727 
728 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
729 in), so the advisory stays bounded and high-signal. The source lines are
730 read into memory in the activity, so the pure scan is unaffected by the
731 workspace being cleaned up.
732 """
733 from froot.adapters.github import GitHubForge
734 from froot.adapters.web_source import load_web_sources
735 
736 forge = GitHubForge()
737 changed = await forge.list_pull_request_files(
738 params.target, params.pr.number
739 )
740 with tempfile.TemporaryDirectory() as tmp:
741 workspace = Path(tmp)
742 await forge.checkout_pull_request(
743 params.target, workspace, params.pr.number
744 )
745 sources = load_web_sources(workspace, changed)
746 candidates = scan_sources(sources)
747 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
748 
749 
750@activity.defn
751async def adjudicate_a11y(
752 params: AdjudicateA11yInput,
753) -> tuple[A11yVerdict, ...]:
754 """Run the model over each flagged candidate; return aligned verdicts."""
755 from froot.adapters.a11y_judge import A11ySourceJudge
756 
757 judge = A11ySourceJudge()
758 verdicts: list[A11yVerdict] = []
759 for candidate in params.candidates:
760 verdicts.append(await judge.adjudicate(candidate))
761 return tuple(verdicts)
762 
763 
764@activity.defn
765async def post_a11y_review(params: PostA11yInput) -> str | None:
766 """Upsert (or clear) the advisory comment; log the ledger row.
767 
768 Posts when there are findings, or when a prior comment must be cleared to
769 "all clear" (true decay — a PR whose gaps were fixed never keeps a stale
770 finding list, the bug the determinism reviewer leaves). A clean PR with
771 no prior comment stays silent, so a clean PR is never spammed.
772 """
773 from froot.adapters.github import GitHubForge
774 
775 forge = GitHubForge()
776 exists = await forge.find_marked_comment(
777 params.target, params.pr.number, A11Y_MARKER
778 )
779 url: str | None = None
780 if should_post(has_findings=bool(params.findings), comment_exists=exists):
781 body = render_a11y_comment(params.findings, params.pr.head_sha)
782 url = await forge.upsert_issue_comment(
783 params.target, params.pr.number, A11Y_MARKER, body
784 )
785 _a11y_log.info(
786 json.dumps(
787 {
788 "event": "loop_outcome",
789 "loop": "a11y-review",
790 "repo": params.target.repo.slug,
791 "pr": params.pr.number,
792 "head_sha": params.pr.head_sha,
793 "findings": len(params.findings),
794 "kinds": sorted({f.kind for f in params.findings}),
795 "comment_url": url,
796 }
797 )
798 )
799 return url
800 
801 
802@activity.defn
803async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
804 """Start a PR's a11y review (idempotent per PR + head SHA)."""
805 from temporalio.common import WorkflowIDReusePolicy
806 from temporalio.exceptions import WorkflowAlreadyStartedError
807 
808 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
809 from froot.workflow.temporal_client import client, task_queue
810 
811 temporal = await client()
812 try:
813 await temporal.start_workflow(
814 PrA11yReviewWorkflow.run,
815 PrA11yReviewParams(target=params.target, pr=params.pr),
816 id=pr_a11y_review_workflow_id(
817 params.target, params.pr.number, params.pr.head_sha
818 ),
819 task_queue=task_queue(),
820 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
821 )
822 except WorkflowAlreadyStartedError:
823 # This (PR, head SHA) already has an a11y review — a no-op, so
824 # re-polling never double-reviews the same commit.
825 return

The dashboard derives from the registry

The acting-loop tabs already iterated the configured loops; the last hard-coded per-loop bit was the icon map keyed by loop name in the renderer. Now the spec carries dashboard_icon, read_model threads it onto each LoopView, and the renderer just uses view.icon.

The icon is resolved from the spec (read_model is the dashboard server context, not a workflow).

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

No more {...}.get(view.loop, ...) — the renderer is loop-blind.

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

The PR-title verb lives in the spec

The title verb (deps / security / dead-code) is a per-loop label like the icon, so it belongs in the spec too. But the draft builder is pure policy — it can't read the registry. So the impure open-PR activity resolves spec.title_prefix and passes it in; compose stays a pure function of its inputs.

A required keyword-only title_prefix; compose no longer knows about loops.

src/froot/policy/compose.py · 191 lines
src/froot/policy/compose.py191 lines · Python
⋯ 85 lines hidden (lines 1–85)
1"""Compose the PR content and the PR labels — pure, no model call.
2 
3Spine-heavy: the PR title/body are deterministic templates over the candidate
4and the model's changelog verdict (the model already did its one job — the
5verdict — so rendering the text costs no model round-trip). froot tags every PR
6with one fixed pair of labels; the per-run changelog/CI signal lives in the
7durable workflow history (and the structured outcome log), not as accumulating
8labels on the PR.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING, assert_never
14 
15from froot.domain.changelog import CleanVerdict, RiskyVerdict, UnknownVerdict
16from froot.domain.ecosystem import (
17 Ecosystem,
18 lockfile_filename,
19 manifest_filename,
21from froot.domain.loop import Loop
22from froot.domain.pull_request import PullRequestDraft
23from froot.policy.naming import branch_name
24 
25if TYPE_CHECKING:
26 from froot.domain.candidate import Candidate
27 from froot.domain.changelog import ChangelogVerdict
28 from froot.domain.removal import Removal
29 from froot.domain.repo import TargetRepo
30 
31_LABEL_NAMESPACE = "froot"
32 
33 
34def pr_labels(loop: Loop = Loop.DEPENDENCY_PATCH) -> tuple[str, str]:
35 """The fixed labels for a loop's PRs: ``(froot, <loop>)``.
36 
37 Deliberately just these two — they mark the PR as froot's work for *this*
38 loop, and nothing more. How the proposal fared (the changelog verdict, the
39 CI result) is recorded durably in the workflow history, not layered onto the
40 PR as labels that pile up across re-runs. The loop label also keeps the two
41 loops' PRs distinguishable to a human filtering the repo.
42 """
43 return (_LABEL_NAMESPACE, loop.value)
44 
45 
46# Tags the comment froot leaves when it closes one of its own PRs (red CI, or a
47# reconcile sweep). The marker lets the close go through the idempotent
48# upsert_issue_comment path, so a retried close edits its note in place instead
49# of stacking a second one.
50CLOSE_MARKER = "<!-- froot:closed -->"
51 
52 
53def _changed_files(ecosystem: Ecosystem) -> str:
54 """Describe which files a bump rewrites, for the PR body.
55 
56 The phrase must match the diff the human approver actually sees. npm
57 rewrites both the manifest and the lockfile (``npm install
58 --package-lock-only`` updates the dependency spec too); uv rewrites only the
59 lockfile, because a patch stays within the existing ``pyproject.toml``
60 constraint, so the manifest is left untouched.
61 """
62 match ecosystem:
63 case Ecosystem.NPM:
64 return f"{manifest_filename(ecosystem)} + lockfile"
65 case Ecosystem.UV:
66 return (
67 f"{lockfile_filename(ecosystem)} only; "
68 f"{manifest_filename(ecosystem)} unchanged"
69 )
70 assert_never(ecosystem)
71 
72 
73def _verdict_summary(verdict: ChangelogVerdict) -> str:
74 """Render the model's changelog framing for the human reviewer."""
75 match verdict:
76 case CleanVerdict():
77 return f"Changelog reads clean. {verdict.rationale}"
78 case RiskyVerdict():
79 concerns = "".join(f"\n- {concern}" for concern in verdict.concerns)
80 return f"Review carefully. {verdict.rationale}{concerns}"
81 case UnknownVerdict():
82 return f"Changelog unavailable. {verdict.rationale}"
83 assert_never(verdict)
84 
85 
86def pull_request_draft(
87 target: TargetRepo,
88 candidate: Candidate,
89 verdict: ChangelogVerdict,
90 loop: Loop = Loop.DEPENDENCY_PATCH,
91 *,
92 title_prefix: str,
93) -> PullRequestDraft:
⋯ 26 lines hidden (lines 94–119)
94 """Build the deterministic PR content for a bump (no model call).
95 
96 Args:
97 target: The repo the PR is opened against (gives the base branch).
98 candidate: The bump being proposed.
99 verdict: The model's changelog framing, surfaced for the reviewer.
100 loop: Which loop is proposing — sets the branch namespace and (via
101 ``candidate.justification``) the body's "why".
102 title_prefix: The PR-title verb (``deps`` / ``security`` …), from spec.
103 
104 Returns:
105 A :class:`PullRequestDraft` ready for the forge to open.
106 """
107 lines = [
108 f"Bumps `{candidate.package}` from {candidate.current} to "
109 f"{candidate.target} ({_changed_files(candidate.ecosystem)}).",
110 ]
111 if candidate.justification is not None:
112 lines += ["", candidate.justification]
113 lines += [
114 "",
115 _verdict_summary(verdict),
116 "",
117 "---",
118 "Opened by froot. froot does not merge; a human approves.",
119 ]
120 return PullRequestDraft(
121 branch=branch_name(candidate, loop),
122 base=target.default_branch,
123 title=(
124 f"{title_prefix}: bump {candidate.package} to {candidate.target}"
125 ),
126 body="\n".join(lines),
127 )
⋯ 64 lines hidden (lines 128–191)
128 
129 
130def removal_pull_request_draft(
131 target: TargetRepo,
132 removal: Removal,
133 loop: Loop = Loop.DEPENDENCY_PATCH,
134 *,
135 title_prefix: str,
136) -> PullRequestDraft:
137 """Build the deterministic PR content for a removal (no model call).
138 
139 A removal carries no changelog verdict (there is no new version to read);
140 its "why" is the ``justification`` the safe-to-remove veto recorded, so that
141 is what the body surfaces. The action rewrites the manifest and lockfile
142 (``npm uninstall`` edits both), which the body states so the diff matches.
143 
144 Args:
145 target: The repo the PR is opened against (gives the base branch).
146 removal: The unused dependency being removed.
147 loop: Which loop is proposing — sets the branch namespace.
148 title_prefix: The PR-title verb (``dead-code`` …), from the loop's spec.
149 
150 Returns:
151 A :class:`PullRequestDraft` ready for the forge to open.
152 """
153 section = "dev dependency" if removal.dev else "dependency"
154 lines = [
155 f"Removes the unused {section} `{removal.package}` "
156 f"from the manifest and lockfile.",
157 ]
158 if removal.justification is not None:
159 lines += ["", removal.justification]
160 lines += [
161 "",
162 "---",
163 "Opened by froot. froot does not merge; a human approves.",
164 ]
165 return PullRequestDraft(
166 branch=branch_name(removal, loop),
167 base=target.default_branch,
168 title=f"{title_prefix}: remove {removal.package} (unused)",
169 body="\n".join(lines),
170 )
171 
172 
173def closed_on_red_comment(failing: tuple[str, ...]) -> str:
174 """The note froot leaves when it closes a PR for failing CI.
175 
176 Names the failing checks (when GitHub reported them) so the human sees why
177 without opening the checks tab, and states froot's contract: it will
178 re-propose the same bump if a newer patch is published. Carries
179 :data:`CLOSE_MARKER` so the close posts through the idempotent comment path.
180 """
181 checks = ", ".join(f"`{name}`" for name in failing)
182 reason = f"CI did not pass ({checks})." if checks else "CI did not pass."
183 return "\n".join(
184 (
185 CLOSE_MARKER,
186 f"froot closed this PR: {reason}",
187 "",
188 "The change wasn't safe to merge, so froot won't leave it open. "
189 "If a newer patch is published, froot will propose it fresh.",
190 )
191 )

The activity resolves the verb from the spec and hands it to the pure builder.

src/froot/workflow/activities.py · 825 lines
src/froot/workflow/activities.py825 lines · Python
⋯ 92 lines hidden (lines 1–92)
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.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.determinism import AnalysisResult, FrontierVerdict
30from froot.domain.pull_request import PullRequestDraft, PullRequestRef
31from froot.domain.removal import Removal
32from froot.domain.repo import TargetRepo
33from froot.domain.work import WorkItem
34from froot.policy.a11y_comment import (
35 A11Y_MARKER,
36 render_a11y_comment,
37 should_post,
39from froot.policy.a11y_scan import scan_sources
40from froot.policy.compose import (
41 pr_labels,
42 pull_request_draft,
43 removal_pull_request_draft,
45from froot.policy.determinism import analyze_workflow_surface
46from froot.policy.naming import (
47 branch_name,
48 bump_workflow_id,
49 pr_a11y_review_workflow_id,
50 pr_review_workflow_id,
52from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
53from froot.workflow.types import (
54 AdjudicateA11yInput,
55 AdjudicateInput,
56 AutoMergeInput,
57 CiCheckInput,
58 CloseInput,
59 DispatchA11yInput,
60 DispatchInput,
61 DispatchReviewInput,
62 GateReviewInput,
63 GateSelfTestInput,
64 JudgeInput,
65 MergeInput,
66 OpenPrInput,
67 PostA11yInput,
68 PostReviewInput,
69 PrA11yReviewParams,
70 PrReviewParams,
71 ReconcileInput,
72 RecordInput,
73 ScanCandidatesInput,
75 
76if TYPE_CHECKING:
77 from froot.domain.loop import Loop
78 from froot.ports.protocols import PackageManager
79 
80_log = logging.getLogger("froot.outcome")
81_review_log = logging.getLogger("froot.review")
82_a11y_log = logging.getLogger("froot.a11y")
83_reconcile_log = logging.getLogger("froot.reconcile")
84_scan_log = logging.getLogger("froot.scan")
85_gate_log = logging.getLogger("froot.gate")
86 
87 
88def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
89 """The directory the manifest lives in (a monorepo subdir, or the root)."""
90 return workspace / target.manifest_dir if target.manifest_dir else workspace
91 
92 
93def _draft_for(params: OpenPrInput) -> PullRequestDraft:
94 """Build the PR draft for the work item — bump vs removal compose.
95 
96 The PR-title verb is the loop's, resolved from its registered spec here (the
97 impure boundary) and passed into the pure draft builders.
98 """
99 from froot.loops import registry
100 
101 item = params.candidate
102 title_prefix = registry.get(params.loop).title_prefix
103 match item:
104 case Candidate():
105 return pull_request_draft(
106 params.target,
107 item,
108 params.verdict,
109 params.loop,
110 title_prefix=title_prefix,
111 )
112 case Removal():
113 return removal_pull_request_draft(
114 params.target, item, params.loop, title_prefix=title_prefix
115 )
⋯ 710 lines hidden (lines 116–825)
116 assert_never(item)
117 
118 
119async def _select_candidates(
120 loop: Loop,
121 target: TargetRepo,
122 package_manager: PackageManager,
123 manifest_dir: Path,
124) -> tuple[int, tuple[WorkItem, ...]]:
125 """Gather this loop's signal from the checkout and select its work items.
126 
127 The one genuinely per-loop seam: dependency-patch reads the available
128 upgrades and picks the highest patch; security-patch reads the installed,
129 asks OSV for advisories, and picks the lowest version clearing each;
130 dead-code reads the unused dependencies a static analyzer flags and vetoes
131 each with the safe-to-remove judge. The impure sources are lazy-imported per
132 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
133 dead-code, model-vetoed) selection.
134 
135 Returns ``(considered, items)`` — ``considered`` is the size of the upstream
136 signal (available upgrades / advisories found / unused deps flagged) so the
137 scan can make its selectivity legible (how much was seen versus kept).
138 
139 The one genuinely per-loop body now lives in each loop's spec ``observe``
140 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
141 selection seam needs no per-loop arm.
142 """
143 from froot.loops import registry
144 
145 return await registry.get(loop).observe(
146 target, package_manager, manifest_dir
147 )
148 
149 
150@activity.defn
151async def scan_candidates(
152 params: ScanCandidatesInput,
153) -> tuple[WorkItem, ...]:
154 """Check out the repo and select this loop's candidates.
155 
156 Emits the tick's selectivity — how much upstream signal was considered
157 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
158 and as a structured ``scan_tick`` log, so the signal stage is legible in the
159 run ledger even on a tick that proposes nothing.
160 """
161 from froot.adapters.github import GitHubForge
162 from froot.adapters.registry import package_manager_for
163 from froot.adapters.telemetry import set_span_attributes
164 
165 forge = GitHubForge()
166 package_manager = package_manager_for(params.target.ecosystem)
167 with tempfile.TemporaryDirectory() as tmp:
168 workspace = Path(tmp)
169 await forge.checkout(params.target, workspace)
170 considered, candidates = await _select_candidates(
171 params.loop,
172 params.target,
173 package_manager,
174 _manifest_dir(params.target, workspace),
175 )
176 selected = len(candidates)
177 dropped = max(considered - selected, 0)
178 set_span_attributes(
179 scan_loop=params.loop.value,
180 scan_repo=params.target.repo.slug,
181 scan_considered=considered,
182 scan_selected=selected,
183 scan_dropped=dropped,
184 )
185 _scan_log.info(
186 json.dumps(
187 {
188 "event": "scan_tick",
189 "loop": params.loop.value,
190 "repo": params.target.repo.slug,
191 "considered": considered,
192 "selected": selected,
193 "dropped": dropped,
194 }
195 )
196 )
197 return candidates
198 
199 
200@activity.defn
201async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
202 """Fetch the candidate's changelog and get the model's typed verdict.
203 
204 The model is froot's one thin, non-load-bearing judgment: a clean verdict
205 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
206 erroring must not stall the spine. A judge failure degrades to
207 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
208 dashboard records the verdict as unknown — rather than failing (and then
209 retrying) the activity. Only the model call is guarded; the fetch is already
210 best-effort (returns ``None``, not an exception). The loop selects what the
211 model is asked (clean-patch vs breaking-change-on-a-security-bump).
212 """
213 from froot.adapters.changelog_http import HttpChangelogSource
214 from froot.adapters.model_judge import PydanticAiJudge
215 
216 candidate = params.candidate
217 if isinstance(candidate, Removal):
218 # A removal already cleared the safe-to-remove veto at scan; there is no
219 # changelog to read, so carry that conclusion straight into the PR
220 # framing (the rationale the veto recorded on the work item).
221 return CleanVerdict(
222 rationale=candidate.justification or "unused; safe to remove"
223 )
224 changelog = await HttpChangelogSource().fetch(candidate)
225 if changelog is None:
226 return UnknownVerdict(rationale="No changelog could be fetched.")
227 try:
228 return await PydanticAiJudge().judge(changelog, params.loop)
229 except Exception as exc:
230 activity.logger.warning(
231 "changelog judge unavailable for %s; degrading to unknown: %r",
232 params.candidate.package,
233 exc,
234 )
235 return UnknownVerdict(
236 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
237 )
238 
239 
240@activity.defn
241async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
242 """Independently deep-review a bump at the gate; fail-closed to a hold.
243 
244 The fourth trust leg (§3.7): a second, adversarial model pass over the
245 changelog, run only when a bump is about to auto-merge. ``clean`` approves
246 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
247 missing changelog or a model error returns a non-clean verdict, so an
248 unreviewable bump never merges unattended. This is the opposite disposition
249 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
250 direction is to hold, and a non-clean verdict already means hold.
251 """
252 from froot.adapters.changelog_http import HttpChangelogSource
253 from froot.adapters.model_judge import PydanticAiJudge
254 
255 candidate = params.candidate
256 verdict: ChangelogVerdict
257 if isinstance(candidate, Removal):
258 # The fourth leg for a removal: independently re-judge that it is safe
259 # to remove (the same check the scan veto ran). No changelog to read;
260 # fail-CLOSED to a hold on a model error, like the bump path.
261 try:
262 verdict = await PydanticAiJudge().judge_removal(candidate)
263 except Exception as exc:
264 activity.logger.warning(
265 "safe-to-remove gate review unavailable for %s; holding: %r",
266 candidate.package,
267 exc,
268 )
269 verdict = UnknownVerdict(
270 rationale=(
271 f"Removal reviewer unavailable ({type(exc).__name__})."
272 )
273 )
274 else:
275 changelog = await HttpChangelogSource().fetch(candidate)
276 if changelog is None:
277 verdict = UnknownVerdict(
278 rationale="No changelog to review; holding (fail-closed)."
279 )
280 else:
281 try:
282 verdict = await PydanticAiJudge().gate_review(
283 changelog, params.loop
284 )
285 except Exception as exc:
286 activity.logger.warning(
287 "gate reviewer unavailable for %s; holding: %r",
288 candidate.package,
289 exc,
290 )
291 verdict = UnknownVerdict(
292 rationale=(
293 f"Gate reviewer unavailable ({type(exc).__name__})."
294 )
295 )
296 _gate_log.info(
297 json.dumps(
298 {
299 "event": "gate_review",
300 "loop": params.loop.value,
301 "pr": params.pr.number,
302 "pr_url": params.pr.url,
303 "package": params.candidate.package,
304 "verdict": verdict.kind,
305 "approved": verdict.kind == "clean",
306 }
307 )
308 )
309 return verdict
310 
311 
312@activity.defn
313async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
314 """Rewrite the manifest+lockfile and open (idempotently) the work item's PR.
315 
316 The one place the action differs by work-item kind: a bump regenerates the
317 lockfile at the target version, a removal deletes the dependency. Both are
318 lockfile-only (no install, no scripts); everything around them — the dedup
319 branch, the checkout, the push, the open — is the same chassis.
320 """
321 from froot.adapters.github import GitHubForge
322 from froot.adapters.registry import package_manager_for
323 
324 forge = GitHubForge()
325 package_manager = package_manager_for(params.target.ecosystem)
326 branch = branch_name(params.candidate, params.loop)
327 existing = await forge.find_open_pull_request(params.target, branch)
328 if existing is not None:
329 return existing
330 draft = _draft_for(params)
331 item = params.candidate
332 with tempfile.TemporaryDirectory() as tmp:
333 workspace = Path(tmp)
334 await forge.checkout(params.target, workspace)
335 manifest_dir = _manifest_dir(params.target, workspace)
336 match item:
337 case Candidate():
338 await package_manager.apply_patch_bump(item, manifest_dir)
339 case Removal():
340 await package_manager.remove_dependency(item, manifest_dir)
341 await forge.push_branch(workspace, branch, draft.title)
342 return await forge.open_pull_request(params.target, draft)
343 
344 
345@activity.defn
346async def check_ci(params: CiCheckInput) -> CIStatus:
347 """Read the repo's CI status for the PR's head commit (the oracle)."""
348 from froot.adapters.github import GitHubForge
349 
350 return await GitHubForge().ci_status(params.target, params.head_sha)
351 
352 
353@activity.defn
354async def record_outcome(params: RecordInput) -> None:
355 """Label the PR and log the run telemetry — the signal-update.
356 
357 The labels carry the loop *and* the judgment environment (the judge model)
358 the PR was opened under, so the gate can count only the track record earned
359 under the current environment and reset it when the model changes (§3.7).
360 """
361 from froot.adapters.github import GitHubForge
362 from froot.config.settings import ModelSettings
363 from froot.policy.environment import env_label
364 
365 outcome = params.outcome
366 item = outcome.candidate
367 labels = (
368 *pr_labels(params.loop),
369 env_label(ModelSettings().ollama_model),
370 )
371 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
372 record = {
373 "event": "loop_outcome",
374 "loop": params.loop.value,
375 "repo": params.target.repo.slug,
376 "package": item.package,
377 "changelog": outcome.verdict.kind,
378 "ci": outcome.ci.kind,
379 "ci_passed": outcome.ci_passed,
380 "pr": outcome.pr.number,
381 "pr_url": outcome.pr.url,
382 }
383 match item:
384 case Candidate():
385 record |= {
386 "action": "bump",
387 "from": str(item.current),
388 "to": str(item.target),
389 }
390 case Removal():
391 record |= {"action": "remove", "dev": item.dev}
392 _log.info(json.dumps(record))
393 
394 
395@activity.defn
396async def dispatch_bump(params: DispatchInput) -> None:
397 """Start the bump loop for a candidate (idempotent per bump identity).
398 
399 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
400 the bump's params — so the running workflow never reads config itself and an
401 in-flight bump keeps the value it was dispatched with.
402 """
403 from temporalio.common import WorkflowIDReusePolicy
404 from temporalio.exceptions import WorkflowAlreadyStartedError
405 
406 from froot.config.settings import BehaviorSettings
407 from froot.workflow.bump_workflow import BumpWorkflow
408 from froot.workflow.temporal_client import client, task_queue
409 from froot.workflow.types import BumpParams
410 
411 temporal = await client()
412 try:
413 await temporal.start_workflow(
414 BumpWorkflow.run,
415 BumpParams(
416 target=params.target,
417 candidate=params.candidate,
418 close_on_red=BehaviorSettings().close_on_red,
419 loop=params.loop,
420 ),
421 id=bump_workflow_id(params.target, params.candidate, params.loop),
422 task_queue=task_queue(),
423 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
424 )
425 except WorkflowAlreadyStartedError:
426 # This bump already has a loop (running or completed) — a no-op, so
427 # re-scanning never opens a second PR for the same bump.
428 return
429 
430 
431@activity.defn
432async def auto_merge_eligible(params: AutoMergeInput) -> bool:
433 """Whether this (repo, loop) class has earned the auto-merge grant.
434 
435 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
436 default), so the common case costs nothing. Otherwise it re-derives the
437 class's standing from the live GitHub history — the same triangulated,
438 windowed, environment-scoped computation the dashboard's shadow gate shows
439 (``read_model.earned_now``) — so the acting gate and the advisory panel can
440 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
441 never an auto-merge.
442 """
443 from datetime import UTC, datetime
444 
445 from froot.config.settings import AutonomySettings, ModelSettings
446 from froot.dashboard import github_source, read_model
447 from froot.policy.environment import environment_slug
448 
449 policy = AutonomySettings().policy()
450 repo = params.target.repo.slug
451 if repo not in policy.allowlisted_repos:
452 return False
453 now = datetime.now(UTC)
454 prs, prs_error = await github_source.fetch((repo,))
455 if prs_error is not None:
456 return False # can't confirm the record -> hold, never merge blind
457 outcomes, _ = await github_source.fetch_outcomes(
458 (repo,), prs, now=now, window_days=policy.window_days
459 )
460 return read_model.earned_now(
461 now,
462 prs,
463 outcomes,
464 repo,
465 params.loop,
466 policy,
467 environment_slug(ModelSettings().ollama_model),
468 )
469 
470 
471@activity.defn
472async def merge_pull_request(params: MergeInput) -> None:
473 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
474 
475 Reached only after the pure machine confirmed clean+green and the class
476 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
477 refuses the merge if the head moved since the gate decided.
478 """
479 from froot.adapters.github import GitHubForge
480 
481 await GitHubForge().merge_pull_request(
482 params.target, params.pr.number, head_sha=params.pr.head_sha
483 )
484 _log.info(
485 json.dumps(
486 {
487 "event": "pr_merged",
488 "loop": params.loop.value,
489 "reason": "auto_merge",
490 "repo": params.target.repo.slug,
491 "pr": params.pr.number,
492 "pr_url": params.pr.url,
493 }
494 )
495 )
496 
497 
498@activity.defn
499async def close_pull_request(params: CloseInput) -> None:
500 """Comment why, then close a red bump's PR and delete its branch.
501 
502 The note goes through the idempotent ``upsert_issue_comment`` and the close
503 itself is idempotent, so a retried close edits its comment in place and
504 never double-posts. The bump's record step still runs after this, so the red
505 outcome is logged either way.
506 """
507 from froot.adapters.github import GitHubForge
508 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
509 
510 forge = GitHubForge()
511 body = closed_on_red_comment(params.failing)
512 await forge.upsert_issue_comment(
513 params.target, params.pr.number, CLOSE_MARKER, body
514 )
515 await forge.close_pull_request(
516 params.target, params.pr.number, params.pr.branch
517 )
518 _log.info(
519 json.dumps(
520 {
521 "event": "pr_closed",
522 "loop": params.loop.value,
523 "reason": "ci_red",
524 "repo": params.target.repo.slug,
525 "pr": params.pr.number,
526 "pr_url": params.pr.url,
527 "failing": list(params.failing),
528 }
529 )
530 )
531 
532 
533@activity.defn
534async def reconcile_open_prs(params: ReconcileInput) -> int:
535 """Close this loop's PRs that a newer candidate or the base has overtaken.
536 
537 Self-contained: lists the repo's open PRs, re-derives this loop's live
538 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
539 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
540 loop's PRs to close, and closes each (deleting its branch). Scoped to the
541 loop's own branch namespace, so the two loops never reconcile each other's
542 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
543 """
544 from froot.adapters.github import GitHubForge
545 from froot.adapters.registry import package_manager_for
546 from froot.config.settings import BehaviorSettings
547 from froot.policy.compose import CLOSE_MARKER
548 from froot.policy.reconcile import reconciliations
549 
550 if not BehaviorSettings().reconcile:
551 return 0
552 # Reconcile is version-supersession cleanup, which only bump loops have: a
553 # removal carries no version to be overtaken. A loop that does not reconcile
554 # (dead-code) is skipped rather than re-running its signal (knip + the veto
555 # judge) every tick only to close nothing. (A removal-specific reconcile —
556 # close when no longer unused — is future work.) The trait is on the spec.
557 from froot.loops import registry
558 
559 if not registry.get(params.loop).reconciles:
560 return 0
561 
562 target, loop = params.target, params.loop
563 forge = GitHubForge()
564 package_manager = package_manager_for(target.ecosystem)
565 open_prs = await forge.list_open_pull_requests(target)
566 with tempfile.TemporaryDirectory() as tmp:
567 workspace = Path(tmp)
568 await forge.checkout(target, workspace)
569 _considered, candidates = await _select_candidates(
570 loop, target, package_manager, _manifest_dir(target, workspace)
571 )
572 closures = reconciliations(open_prs, candidates, loop)
573 for closure in closures:
574 await forge.upsert_issue_comment(
575 target, closure.pr.number, CLOSE_MARKER, closure.comment
576 )
577 await forge.close_pull_request(
578 target, closure.pr.number, closure.pr.branch
579 )
580 if closures:
581 _reconcile_log.info(
582 json.dumps(
583 {
584 "event": "reconcile",
585 "loop": loop.value,
586 "repo": target.repo.slug,
587 "closed": len(closures),
588 "prs": [closure.pr.number for closure in closures],
589 }
590 )
591 )
592 return len(closures)
593 
594 
595@activity.defn
596async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
597 """Run the adversarial gate probe against the live policy; alarm on escape.
598 
599 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
600 known-bad class histories a healthy gate must refuse, scored against the
601 policy froot is *actually running* (config and all). Any escape — a bad
602 class the live gate would grant — is logged at ERROR (the alarm) so it
603 surfaces in telemetry the moment config drifts; a clean pass logs a
604 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
605 repo-independent (the ``target``/``loop`` are only the log's context).
606 """
607 from froot.config.settings import AutonomySettings
608 from froot.policy.gate_probe import gate_escapes
609 
610 escaped = gate_escapes(AutonomySettings().policy())
611 record = json.dumps(
612 {
613 "event": "gate_selftest",
614 "loop": params.loop.value,
615 "repo": params.target.repo.slug,
616 "healthy": not escaped,
617 "escaped": list(escaped),
618 }
619 )
620 if escaped:
621 _gate_log.error(record) # an alarm: the gate would trust a bad class
622 else:
623 _gate_log.info(record)
624 return escaped
625 
626 
627# ── The determinism reviewer (the transitive ring) ──────────────────────────
628@activity.defn
629async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
630 """List the repo's open PRs for the determinism reviewer to consider."""
631 from froot.adapters.github import GitHubForge
632 
633 return await GitHubForge().list_open_pull_requests(target)
634 
635 
636@activity.defn
637async def dispatch_pr_review(params: DispatchReviewInput) -> None:
638 """Start a PR's determinism review (idempotent per PR + head SHA)."""
639 from temporalio.common import WorkflowIDReusePolicy
640 from temporalio.exceptions import WorkflowAlreadyStartedError
641 
642 from froot.workflow.pr_review_workflow import PrReviewWorkflow
643 from froot.workflow.temporal_client import client, task_queue
644 
645 temporal = await client()
646 try:
647 await temporal.start_workflow(
648 PrReviewWorkflow.run,
649 PrReviewParams(target=params.target, pr=params.pr),
650 id=pr_review_workflow_id(
651 params.target, params.pr.number, params.pr.head_sha
652 ),
653 task_queue=task_queue(),
654 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
655 )
656 except WorkflowAlreadyStartedError:
657 # This (PR, head SHA) already has a review — a no-op, so re-polling
658 # never double-reviews the same commit.
659 return
660 
661 
662@activity.defn
663async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
664 """Check out the PR head and analyze the workflow surface for hazards."""
665 from froot.adapters.github import GitHubForge
666 from froot.adapters.source_tree import load_modules
667 from froot.config.settings import ReviewSettings
668 
669 forge = GitHubForge()
670 with tempfile.TemporaryDirectory() as tmp:
671 workspace = Path(tmp)
672 await forge.checkout_pull_request(
673 params.target, workspace, params.pr.number
674 )
675 # The ASTs and source lines are read into memory here, so the analysis
676 # below is unaffected by the workspace being cleaned up.
677 modules = load_modules(workspace)
678 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
679 
680 
681@activity.defn
682async def adjudicate_frontier(
683 params: AdjudicateInput,
684) -> tuple[FrontierVerdict, ...]:
685 """Run the model over each frontier item; return aligned verdicts."""
686 from froot.adapters.determinism_judge import DeterminismFrontierJudge
687 
688 judge = DeterminismFrontierJudge()
689 verdicts: list[FrontierVerdict] = []
690 for item in params.frontier:
691 verdicts.append(await judge.adjudicate(item))
692 return tuple(verdicts)
693 
694 
695@activity.defn
696async def post_review(params: PostReviewInput) -> str | None:
697 """Upsert the advisory comment (when there are findings); log the ledger."""
698 from froot.adapters.github import GitHubForge
699 
700 body = render_review_comment(params.findings, params.pr.head_sha)
701 url: str | None = None
702 if body is not None:
703 url = await GitHubForge().upsert_issue_comment(
704 params.target, params.pr.number, REVIEW_MARKER, body
705 )
706 _review_log.info(
707 json.dumps(
708 {
709 "event": "loop_outcome",
710 "loop": "determinism-review",
711 "repo": params.target.repo.slug,
712 "pr": params.pr.number,
713 "head_sha": params.pr.head_sha,
714 "findings": len(params.findings),
715 "rules": sorted({f.rule for f in params.findings}),
716 "comment_url": url,
717 }
718 )
719 )
720 return url
721 
722 
723# ── The a11y reviewer (the source-level design-system ring) ──────────────────
724@activity.defn
725async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
726 """Check out the PR head and scan its changed templates for a11y risks.
727 
728 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
729 in), so the advisory stays bounded and high-signal. The source lines are
730 read into memory in the activity, so the pure scan is unaffected by the
731 workspace being cleaned up.
732 """
733 from froot.adapters.github import GitHubForge
734 from froot.adapters.web_source import load_web_sources
735 
736 forge = GitHubForge()
737 changed = await forge.list_pull_request_files(
738 params.target, params.pr.number
739 )
740 with tempfile.TemporaryDirectory() as tmp:
741 workspace = Path(tmp)
742 await forge.checkout_pull_request(
743 params.target, workspace, params.pr.number
744 )
745 sources = load_web_sources(workspace, changed)
746 candidates = scan_sources(sources)
747 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
748 
749 
750@activity.defn
751async def adjudicate_a11y(
752 params: AdjudicateA11yInput,
753) -> tuple[A11yVerdict, ...]:
754 """Run the model over each flagged candidate; return aligned verdicts."""
755 from froot.adapters.a11y_judge import A11ySourceJudge
756 
757 judge = A11ySourceJudge()
758 verdicts: list[A11yVerdict] = []
759 for candidate in params.candidates:
760 verdicts.append(await judge.adjudicate(candidate))
761 return tuple(verdicts)
762 
763 
764@activity.defn
765async def post_a11y_review(params: PostA11yInput) -> str | None:
766 """Upsert (or clear) the advisory comment; log the ledger row.
767 
768 Posts when there are findings, or when a prior comment must be cleared to
769 "all clear" (true decay — a PR whose gaps were fixed never keeps a stale
770 finding list, the bug the determinism reviewer leaves). A clean PR with
771 no prior comment stays silent, so a clean PR is never spammed.
772 """
773 from froot.adapters.github import GitHubForge
774 
775 forge = GitHubForge()
776 exists = await forge.find_marked_comment(
777 params.target, params.pr.number, A11Y_MARKER
778 )
779 url: str | None = None
780 if should_post(has_findings=bool(params.findings), comment_exists=exists):
781 body = render_a11y_comment(params.findings, params.pr.head_sha)
782 url = await forge.upsert_issue_comment(
783 params.target, params.pr.number, A11Y_MARKER, body
784 )
785 _a11y_log.info(
786 json.dumps(
787 {
788 "event": "loop_outcome",
789 "loop": "a11y-review",
790 "repo": params.target.repo.slug,
791 "pr": params.pr.number,
792 "head_sha": params.pr.head_sha,
793 "findings": len(params.findings),
794 "kinds": sorted({f.kind for f in params.findings}),
795 "comment_url": url,
796 }
797 )
798 )
799 return url
800 
801 
802@activity.defn
803async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
804 """Start a PR's a11y review (idempotent per PR + head SHA)."""
805 from temporalio.common import WorkflowIDReusePolicy
806 from temporalio.exceptions import WorkflowAlreadyStartedError
807 
808 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
809 from froot.workflow.temporal_client import client, task_queue
810 
811 temporal = await client()
812 try:
813 await temporal.start_workflow(
814 PrA11yReviewWorkflow.run,
815 PrA11yReviewParams(target=params.target, pr=params.pr),
816 id=pr_a11y_review_workflow_id(
817 params.target, params.pr.number, params.pr.head_sha
818 ),
819 task_queue=task_queue(),
820 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
821 )
822 except WorkflowAlreadyStartedError:
823 # This (PR, head SHA) already has an a11y review — a no-op, so
824 # re-polling never double-reviews the same commit.
825 return

Tests: zero behavior change, single-sourced seams

Behavior equivalence is carried by the existing suite: the scan, judge, reconcile, and dashboard tests now run through the registry and still pass — including test_reconcile_skips_dead_code (the skip happens without re-scanning) and test_scan_candidates_dead_code_vetoes_unsafe_removals (the veto-at-signal). The new registry tests guard the seams the spec now owns.

Disposition, the title verb, judge-context placement, the reconcile trait.

tests/test_loop_registry.py · 54 lines
tests/test_loop_registry.py54 lines · Python
⋯ 19 lines hidden (lines 1–19)
1"""The loop registry — the spine reads it instead of branching on the enum.
2 
3These guard the per-loop seams the registry owns: the disposition, the
4changelog-judge framing, the reconcile trait, the PR-title verb, and the
5dashboard icon — so a loop's identity stays single-sourced as loops multiply.
6Behavior equivalence of each loop's ``observe`` is covered by the existing scan
7tests, which now route through the registry.
8"""
9 
10from __future__ import annotations
11 
12from froot.adapters.model_judge import _loop_context
13from froot.domain.loop import Loop
14from froot.loops import registry
15from froot.loops.registry import Disposition
16 
17_ACTING = (Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH, Loop.DEAD_CODE)
18 
19 
20def test_acting_loops_registered_as_commit_or_revert() -> None:
21 specs = {spec.loop: spec for spec in registry.all_specs()}
22 for loop in _ACTING:
23 assert specs[loop].disposition is Disposition.COMMIT_OR_REVERT
24 
25 
26def test_title_prefix_is_the_per_loop_pr_verb() -> None:
27 # The PR-title verb is a per-loop label single-sourced in the spec (not
28 # derivable from the loop name), so a new loop carries its own.
29 assert registry.get(Loop.DEPENDENCY_PATCH).title_prefix == "deps"
30 assert registry.get(Loop.SECURITY_PATCH).title_prefix == "security"
31 assert registry.get(Loop.DEAD_CODE).title_prefix == "dead-code"
32 
33 
34def test_judge_context_present_for_changelog_loops_absent_for_dead_code() -> (
35 None
36):
37 # Dependency- and security-patch judge a changelog in-loop (a framing line);
38 # dead-code judges at the signal (a removal veto), so it carries no context.
39 assert registry.get(Loop.DEPENDENCY_PATCH).judge_context is not None
40 assert registry.get(Loop.SECURITY_PATCH).judge_context is not None
41 assert registry.get(Loop.DEAD_CODE).judge_context is None
42 
43 
44def test_loop_context_reads_the_registry() -> None:
45 for loop in (Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH):
46 assert _loop_context(loop) == registry.get(loop).judge_context
47 
48 
49def test_reconciles_true_for_bump_loops_false_for_dead_code() -> None:
50 # Version-supersession reconcile only applies to version-bearing bumps; a
51 # removal has no version to be overtaken, so dead-code declares it skips.
52 assert registry.get(Loop.DEPENDENCY_PATCH).reconciles is True
53 assert registry.get(Loop.SECURITY_PATCH).reconciles is True
54 assert registry.get(Loop.DEAD_CODE).reconciles is False

The discriminator: a spec icon change flows to the rendered tab, no renderer edit.

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

The whole cut is green at every commit: ruff, mypy, the full test suite, and both the lexical and transitive determinism gates. Adding an acting loop now needs a Loop enum member and a loops/<name>.py spec — nothing else.