frootยท

froot, the essential reading

A code-level review ยท the essential 25%

froot

Durable maintenance loops, pointed at any repo.

This is the reviewer's cut. It keeps the parts of the codebase you would insist on seeing before vouching for it, and leaves the exhaustive line-by-line walk to the full edition. Expand any code block to read more around a snippet.

๐Ÿ”ต chassis ยท deterministic & durable ๐ŸŸฃ model ยท thin judgment ๐ŸŸข terrain ยท external truth ๐ŸŸ  steward ยท the human
2,986
lines of source
42
modules
4
ports
1
model call
0
databases

What froot is

froot runs autonomous code-maintenance loops on Temporal and points them at GitHub repositories. A loop watches a repo for one kind of decay. It proposes a bounded fix as a pull request, lets the repo's own CI verify the change, and records the outcome. A human approves every merge.

The first loop keeps npm dependencies patched. froot is not that loop. It is the chassis the loop runs on: one durable substrate, many specialized loops, any number of repos. Almost everything in the codebase is chassis. The loop-specific parts are small on purpose.

ย โ‘  signalย 

ย โ‘ก actionย 

ย โ‘ข durably wait on CI (minsโ€“hrs)ย 

ย greenย 

ย red / timeoutย 

ย โ‘ฃ commitย 

ย โ‘ค updateย 

ย decays into the next tick's signalย 

โฐ Durable schedule
(Temporal timer)

๐Ÿ” Scan candidates
(checkout ยท npm ยท deterministic)

๐Ÿค– Judge changelog
(thin pydantic-ai)

๐Ÿ”€ One PR per bump
(manifest + lockfile)

โœ… Repo's own CI
green?

๐Ÿ‘ค Human merges
(approval gate)

โš‘ Leave PR open
(flagged red)

๐Ÿ“Š Record outcome

๐Ÿ™ GitHub
outcome ledger

๐Ÿ“ˆ ClickStack
run telemetry

The dependency-patch loop. Nodes are colored by who owns each step. The dashed edge is the loop closing: each outcome decays into the next tick's signal.

The tour goes from the center out. We start with the pure types, move through the loop logic and the seam to the outside world, then reach the durable spine and the deployment. That is the direction the dependencies point.

Orientation1๐ŸŽฌ Map + table

The shape of the whole thing

src/froot/__init__.py

froot is a functional core wrapped in an imperative shell. The rule is one line: imports point only inward. At the center, the pure core never imports Temporal, npm, a GitHub client, or httpx, which keeps every business rule sealed off from the machinery that runs it. The shell at the edge depends on all of them. It owns no logic.

๐Ÿ’Ž Pure core (no I/O, no framework)

๐Ÿชก The seam

๐ŸŒ Impure shell

implements

interprets effects via

๐Ÿš€ worker / scan_starter
(entrypoints)

โš™๏ธ workflow spine
(Temporal workflows + activities)

๐Ÿ”Œ adapters
(npm ยท github ยท model ยท http ยท otel)

๐Ÿ“œ ports
(typed Protocols)

๐Ÿงฎ policy
(candidates ยท naming ยท compose ยท state-machine)

๐ŸงŠ domain
(frozen, closed value objects)

Dependencies point inward. The pure core has no I/O and no framework. The shell implements the ports and interprets the core's effects. The seam is four typed Protocols.
LayerModulesLinesIts one job
๐ŸงŠ domainversion ยท candidate ยท ci ยท state ยท events ยท effects ยท outcome ยท โ€ฆ727frozen value objects; illegal states cannot be built
๐Ÿงฎ policycandidates ยท naming ยท compose ยท state_machine363pure decisions: selection, idempotency keys, PR text, the loop's transitions
๐Ÿ“œ portsprotocols.py114four Protocols; the seam to the impure world
๐Ÿ”Œ adaptersnpm ยท github ยท model_judge ยท changelog_http ยท telemetry ยท โ€ฆ845real integrations: npm, git, GitHub, the model, OTEL
โš™๏ธ workflowscan_workflow ยท bump_workflow ยท activities ยท โ€ฆ521the durable Temporal spine: two workflows, six activities
๐Ÿš€ entry / configworker ยท scan_starter ยท settings284the runnable worker, the go-live trigger, all env config
The source tree by layer. The acts below follow these layers from the inside out.
The pure core2๐ŸŽฌ Three proofs

Illegal states you cannot build

src/froot/domain/version.pysrc/froot/domain/candidate.pysrc/froot/domain/ci.py

The domain is a dozen modules of frozen, closed value objects. The governing idea is that the states the loop must never reach cannot be constructed at all. That is a strong claim, so here are three proofs a reviewer can check directly.

Proof one: a patch bump has a precise definition

The loop's signal is "a higher patch of a dependency exists." Everything turns on what counts as a patch. One method defines it.

src/froot/domain/version.py ยท 123 lines
src/froot/domain/version.py123 lines ยท Python
โ‹ฏ 91 lines hidden (lines 1โ€“91)
1"""Semantic versions and the patch-bump relation.
2 
3:class:`Version` is the value object the whole loop turns on: the deterministic
4signal is "a higher patch of a dependency exists", and *patch* is a precise
5relation between two versions, encoded in :meth:`Version.is_patch_bump_of`.
6Because :class:`~froot.domain.candidate` enforces that relation at construction,
7a candidate that is *not* a patch bump is unrepresentable.
8 
9Parsing untrusted version strings is a boundary concern, so
10:meth:`Version.parse` returns a :class:`~froot.result.Result`, not an exception.
11"""
12 
13from __future__ import annotations
14 
15import re
16from functools import total_ordering
17 
18from froot.domain.base import Frozen
19from froot.result import Err, Ok, Result
20 
21# major.minor.patch with an optional -prerelease and an ignored +build segment.
22_SEMVER = re.compile(
23 r"""
24 ^\s*v? # tolerate a leading 'v'
25 (?P<major>0|[1-9]\d*)\.
26 (?P<minor>0|[1-9]\d*)\.
27 (?P<patch>0|[1-9]\d*)
28 (?:-(?P<prerelease>[0-9A-Za-z.-]+))?
29 (?:\+[0-9A-Za-z.-]+)? # build metadata: parsed away, not compared
30 \s*$
31 """,
32 re.VERBOSE,
34 
35 
36@total_ordering
37class Version(Frozen):
38 """A semantic version, ordered and comparable.
39 
40 Attributes:
41 major: The major component (breaking changes).
42 minor: The minor component (backward-compatible features).
43 patch: The patch component (backward-compatible fixes).
44 prerelease: The prerelease tag (e.g. ``rc.1``), or ``None`` for a
45 stable release. A prerelease sorts below its stable release.
46 """
47 
48 major: int
49 minor: int
50 patch: int
51 prerelease: str | None = None
52 
53 @classmethod
54 def parse(cls, text: str) -> Result[Version, str]:
55 """Parse a semantic-version string at the untrusted boundary.
56 
57 Args:
58 text: A version string such as ``"1.4.2"`` or ``"2.0.0-rc.1"``.
59 A leading ``v`` and ``+build`` metadata are tolerated.
60 
61 Returns:
62 ``Ok(Version)`` on success, or ``Err(message)`` if the string is
63 not a recognizable semantic version.
64 """
65 match = _SEMVER.match(text)
66 if match is None:
67 return Err(f"not a semantic version: {text!r}")
68 return Ok(
69 cls(
70 major=int(match["major"]),
71 minor=int(match["minor"]),
72 patch=int(match["patch"]),
73 prerelease=match["prerelease"],
74 )
75 )
76 
77 @property
78 def is_stable(self) -> bool:
79 """True when this is a stable release (no prerelease tag)."""
80 return self.prerelease is None
81 
82 @property
83 def _sort_key(self) -> tuple[int, int, int, int, str]:
84 # A stable release outranks its prereleases: (โ€ฆ, 1, "") > (โ€ฆ, 0, tag).
85 # Prerelease-vs-prerelease compares the tag lexically, NOT by SemVer ยง11
86 # numeric-identifier rules (so "rc.2" sorts after "rc.10"). That is
87 # deliberate: the patch loop only ever compares stable versions
88 # (is_patch_bump_of requires both ends stable), so this path is unused.
89 rank = 1 if self.prerelease is None else 0
90 return (self.major, self.minor, self.patch, rank, self.prerelease or "")
91 
92 def is_patch_bump_of(self, other: Version) -> bool:
93 """Whether this version is a clean patch-level upgrade of ``other``.
94 
95 A patch bump keeps the major and minor fixed, raises the patch, and is
96 stable on both ends. Prereleases are never clean patch bumps: a loop
97 that proposed ``1.4.2 -> 1.4.3-rc.1`` would be proposing instability.
98 
99 Args:
100 other: The currently installed version.
101 
102 Returns:
103 True iff ``self`` is the same ``major.minor`` as ``other`` with a
104 strictly higher, stable patch.
105 """
106 return (
107 self.is_stable
108 and other.is_stable
109 and self.major == other.major
110 and self.minor == other.minor
111 and self.patch > other.patch
112 )
โ‹ฏ 11 lines hidden (lines 113โ€“123)
113 
114 def __lt__(self, other: object) -> bool:
115 """Order by ``major.minor.patch`` then prerelease rank."""
116 if not isinstance(other, Version):
117 return NotImplemented
118 return self._sort_key < other._sort_key
119 
120 def __str__(self) -> str:
121 """Render as ``major.minor.patch[-prerelease]``."""
122 base = f"{self.major}.{self.minor}.{self.patch}"
123 return f"{base}-{self.prerelease}" if self.prerelease else base

A version is a clean patch bump of another only when both are stable, the major and minor match, and the patch strictly increases. Each clause closes one way a "bump" could smuggle in risk: a prerelease, a minor change, a downgrade.

Proof two: a candidate enforces that definition at construction

PatchCandidate is the loop's unit of work. Its one invariant runs in a validator, so a candidate that is not a clean patch bump simply cannot exist.

src/froot/domain/candidate.py ยท 73 lines
src/froot/domain/candidate.py73 lines ยท Python
โ‹ฏ 37 lines hidden (lines 1โ€“37)
1"""A patch candidate: a dependency with a clean patch-level upgrade available.
2 
3This is the loop's bounded unit of work. Its single, load-bearing invariant โ€”
4the target *is* a patch bump of the current version โ€” is enforced at
5construction, so the rest of the system can treat any :class:`PatchCandidate` it
6holds as already-validated. A candidate that changes the major or minor, goes
7backward, or steps onto a prerelease simply cannot be built.
8"""
9 
10from __future__ import annotations
11 
12from typing import Self
13 
14from pydantic import Field, model_validator
15 
16from froot.domain.base import Frozen
17from froot.domain.ecosystem import Ecosystem
18from froot.domain.version import Version
19 
20 
21class PatchCandidate(Frozen):
22 """A proposed patch-level upgrade of a single dependency.
23 
24 Attributes:
25 package: The dependency's name (e.g. ``"left-pad"`` or a scoped
26 ``"@scope/pkg"``).
27 ecosystem: The package manager the dependency belongs to.
28 current: The installed version.
29 target: The proposed version โ€” guaranteed a clean patch bump of
30 ``current`` (see :meth:`Version.is_patch_bump_of`).
31 """
32 
33 package: str = Field(min_length=1)
34 ecosystem: Ecosystem
35 current: Version
36 target: Version
37 
38 @model_validator(mode="after")
39 def _require_patch_bump(self) -> Self:
40 """Reject any candidate whose target is not a clean patch bump."""
41 if not self.target.is_patch_bump_of(self.current):
42 raise ValueError(
43 f"{self.target} is not a clean patch bump of {self.current} "
44 f"for {self.package!r}"
45 )
46 return self
โ‹ฏ 27 lines hidden (lines 47โ€“73)
47 
48 def __str__(self) -> str:
49 """Render as ``package current -> target``."""
50 return f"{self.package} {self.current} -> {self.target}"
51 
52 
53class AvailableUpgrade(Frozen):
54 """An installed dependency and the published versions it could move to.
55 
56 The raw material the package-manager adapter reports (e.g. from ``npm
57 outdated`` + the published version list). It is deliberately *not* yet a
58 :class:`PatchCandidate`: choosing which available version is the right
59 patch-level target is business logic, and it lives in the pure
60 :func:`froot.policy.candidates.select_patch_candidates`, not in the adapter.
61 
62 Attributes:
63 package: The dependency's name.
64 ecosystem: The package manager it belongs to.
65 current: The installed version.
66 available: The published versions that could be upgraded to (any
67 order; the policy selects among them).
68 """
69 
70 package: str = Field(min_length=1)
71 ecosystem: Ecosystem
72 current: Version
73 available: tuple[Version, ...]

Proof three: a pending CI is not assignable as an outcome

CI is froot's verification, and froot never re-runs a repo's tests. The risk is that a still-running check gets mistaken for a verdict. The type system rules it out. There are two unions, and the relationship between them is the point.

TerminalCIStatus โ€” the only readings recordable as an outcome

is_terminal() โ‡’ false
(spine must keep polling)

๐Ÿ“ก ci_status reading

โณ CIPending
(non-terminal โ€” keep waiting)

โœ… CIPassed

โŒ CIFailed
(+ failing names)

โŠ˜ CIAbsent
(no checks)

โŒ› CITimedOut
(deadline elapsed)

Five readings, but only four are terminal. TerminalCIStatus is a strict subset of CIStatus, and the recorded outcome is typed to the subset.
src/froot/domain/ci.py ยท 68 lines
src/froot/domain/ci.py68 lines ยท Python
โ‹ฏ 51 lines hidden (lines 1โ€“51)
1"""CI status โ€” the loop's verification, owned by the target repo's own CI.
2 
3froot never re-runs a repo's tests (SPEC: CI is the oracle). It opens a PR and
4reads the repo's existing checks. :class:`CIPending` means keep waiting;
5everything else is terminal โ€” :class:`CIAbsent` (the repo has no checks to
6trust) and :class:`CITimedOut` (froot stopped waiting) are distinct from a real
7:class:`CIPassed` / :class:`CIFailed` so the recorded outcome never conflates
8"green" with "couldn't tell". The recorded outcome is typed to the
9:data:`TerminalCIStatus` subset, so a pending status can never be recorded.
10"""
11 
12from __future__ import annotations
13 
14from typing import Annotated, Literal, TypeIs
15 
16from pydantic import Field
17 
18from froot.domain.base import Frozen
19 
20 
21class CIPending(Frozen):
22 """Checks are still running; the loop keeps waiting (not terminal)."""
23 
24 kind: Literal["pending"] = "pending"
25 
26 
27class CIPassed(Frozen):
28 """All required checks succeeded โ€” the PR is ready for a human merge."""
29 
30 kind: Literal["passed"] = "passed"
31 
32 
33class CIFailed(Frozen):
34 """At least one check failed; the PR stays open, flagged for the human."""
35 
36 kind: Literal["failed"] = "failed"
37 failing: tuple[str, ...] = ()
38 
39 
40class CIAbsent(Frozen):
41 """The repo configured no checks for this commit โ€” nothing to verify."""
42 
43 kind: Literal["absent"] = "absent"
44 
45 
46class CITimedOut(Frozen):
47 """The loop's CI-wait deadline elapsed before checks resolved."""
48 
49 kind: Literal["timed_out"] = "timed_out"
50 
51 
52# A point-in-time CI reading. Only ``CIPending`` is non-terminal.
53CIStatus = Annotated[
54 CIPending | CIPassed | CIFailed | CIAbsent | CITimedOut,
55 Field(discriminator="kind"),
57 
58# A terminal CI reading โ€” every reading except still-pending. The recorded loop
59# outcome is typed to this, so a pending CI can never be recorded as an outcome.
60TerminalCIStatus = Annotated[
61 CIPassed | CIFailed | CIAbsent | CITimedOut,
62 Field(discriminator="kind"),
64 
65 
66def is_terminal(status: CIStatus) -> TypeIs[TerminalCIStatus]:
67 """Whether a CI reading is final (narrows to :data:`TerminalCIStatus`)."""
68 return not isinstance(status, CIPending)
The pure core3๐ŸŽฌ State diagram + trace

The loop as a pure function

src/froot/policy/state_machine.pysrc/froot/domain/effects.py

The loop's every move is decided here, by a pure function the Temporal spine only drives. advance takes the current state and a decided event and returns a Transition: the next state plus the effects the spine should run. There is no I/O and no clock, so a transition replays deterministically and tests in microseconds.

start(candidate)

ChangelogJudged / emit OpenPullRequest

PullRequestReady / emit AwaitCi

CiResolved(terminal) / emit RecordOutcome

CiResolved(pending) โ‡’ REJECTED (no move)

OutcomeRecorded โ‡’ IGNORED (loop complete)

Discovered

Judged

AwaitingCi

Recorded

Any unexpected event in any
state โ‡’ REJECTED transition,
never an exception.

The bump lifecycle. Solid edges advance and emit one effect. The self-loop is a still-pending CI being rejected so the spine keeps waiting. The terminal acknowledgement is a no-op.
src/froot/policy/state_machine.py ยท 171 lines
src/froot/policy/state_machine.py171 lines ยท Python
โ‹ฏ 93 lines hidden (lines 1โ€“93)
1"""The bump loop state machine: pure transitions, effects as data.
2 
3``start`` and ``advance`` are pure: given the current state and a decided event,
4they return a :class:`Transition` โ€” the next state plus the effects the spine
5should run. No I/O, no clock, so a transition replays deterministically and is
6fully testable. Dispatch is per-state and ends in ``assert_never`` so the type
7checker proves every state is handled; an event a state does not expect yields a
8``REJECTED`` transition (no state change), never an exception.
9 
10The loop is linear: each advance emits exactly one effect, the spine runs it to
11obtain the next event, and the cycle repeats until ``Recorded`` (no effects). A
12:class:`~froot.domain.events.CiResolved` that is still pending is rejected โ€” the
13spine must finish waiting before the machine will close the loop, so an outcome
14can never be recorded against an unresolved CI status.
15"""
16 
17from __future__ import annotations
18 
19from enum import StrEnum
20from typing import TYPE_CHECKING, assert_never
21 
22from froot.domain.base import Frozen
23from froot.domain.ci import is_terminal
24from froot.domain.effects import (
25 AwaitCi,
26 Effect,
27 JudgeChangelog,
28 OpenPullRequest,
29 RecordOutcome,
31from froot.domain.events import (
32 ChangelogJudged,
33 CiResolved,
34 LoopEvent,
35 OutcomeRecorded,
36 PullRequestReady,
38from froot.domain.outcome import LoopOutcome
39from froot.domain.state import (
40 AwaitingCi,
41 BumpState,
42 Discovered,
43 Judged,
44 Recorded,
46 
47if TYPE_CHECKING:
48 from froot.domain.candidate import PatchCandidate
49 
50 
51class TransitionKind(StrEnum):
52 """The disposition of an :func:`advance` call."""
53 
54 ADVANCED = "advanced"
55 IGNORED = "ignored"
56 REJECTED = "rejected"
57 
58 
59class Transition(Frozen):
60 """The result of a transition.
61 
62 Attributes:
63 kind: ``ADVANCED`` (moved and/or emitted effects), ``IGNORED`` (a legal
64 no-op, e.g. the terminal acknowledgement), or ``REJECTED`` (the
65 event is not valid in this state).
66 next: The resulting state (unchanged for ``IGNORED``/``REJECTED``).
67 effects: The effects the spine should run, in order. Empty terminates
68 the loop driver.
69 reason: A short explanation for an ``IGNORED``/``REJECTED`` transition.
70 """
71 
72 kind: TransitionKind
73 next: BumpState
74 effects: tuple[Effect, ...] = ()
75 reason: str | None = None
76 
77 
78def _advanced(nxt: BumpState, *effects: Effect) -> Transition:
79 return Transition(kind=TransitionKind.ADVANCED, next=nxt, effects=effects)
80 
81 
82def _rejected(state: BumpState, reason: str) -> Transition:
83 return Transition(kind=TransitionKind.REJECTED, next=state, reason=reason)
84 
85 
86def start(candidate: PatchCandidate) -> Transition:
87 """The opening transition: enter ``Discovered`` and judge the changelog."""
88 return _advanced(
89 Discovered(candidate=candidate),
90 JudgeChangelog(candidate=candidate),
91 )
92 
93 
94def advance(state: BumpState, event: LoopEvent) -> Transition:
95 """Advance the loop one step (pure).
96 
97 Args:
98 state: The current bump state.
99 event: The decided event that just occurred.
100 
101 Returns:
102 The :class:`Transition` to apply.
103 """
104 match state:
105 case Discovered():
106 return _from_discovered(state, event)
107 case Judged():
108 return _from_judged(state, event)
109 case AwaitingCi():
110 return _from_awaiting_ci(state, event)
111 case Recorded():
112 return _from_recorded(state, event)
113 assert_never(state)
โ‹ฏ 30 lines hidden (lines 114โ€“143)
114 
115 
116def _from_discovered(state: Discovered, event: LoopEvent) -> Transition:
117 match event:
118 case ChangelogJudged():
119 return _advanced(
120 Judged(candidate=state.candidate, verdict=event.verdict),
121 OpenPullRequest(
122 candidate=state.candidate, verdict=event.verdict
123 ),
124 )
125 case _:
126 return _rejected(state, f"unexpected {event.kind} in discovered")
127 
128 
129def _from_judged(state: Judged, event: LoopEvent) -> Transition:
130 match event:
131 case PullRequestReady():
132 return _advanced(
133 AwaitingCi(
134 candidate=state.candidate,
135 verdict=state.verdict,
136 pr=event.pr,
137 ),
138 AwaitCi(pr=event.pr),
139 )
140 case _:
141 return _rejected(state, f"unexpected {event.kind} in judged")
142 
143 
144def _from_awaiting_ci(state: AwaitingCi, event: LoopEvent) -> Transition:
145 match event:
146 case CiResolved():
147 if not is_terminal(event.status):
148 return _rejected(state, "ci still pending; the spine must wait")
149 outcome = LoopOutcome(
150 candidate=state.candidate,
151 verdict=state.verdict,
152 pr=state.pr,
153 ci=event.status,
154 )
155 return _advanced(
156 Recorded(outcome=outcome), RecordOutcome(outcome=outcome)
157 )
158 case _:
159 return _rejected(state, f"unexpected {event.kind} in awaiting_ci")
โ‹ฏ 12 lines hidden (lines 160โ€“171)
160 
161 
162def _from_recorded(state: Recorded, event: LoopEvent) -> Transition:
163 # Terminal: the only expected event is the record acknowledgement, a no-op
164 # that ends the driver loop (no effects).
165 match event:
166 case OutcomeRecorded():
167 return Transition(
168 kind=TransitionKind.IGNORED, next=state, reason="loop complete"
169 )
170 case _:
171 return _rejected(state, f"unexpected {event.kind} after recorded")

advance matches on the state, delegates to a per-state helper, and ends in assert_never, so the checker proves every state is handled. Look at _from_awaiting_ci. A CiResolved that is still pending returns a rejected transition: the state does not change and nothing is raised. That is how the "you cannot record an unresolved CI" rule reads in the pure layer. The machine refuses to move, and the spine keeps polling.

StateExpected eventNext stateEffect emitted
DiscoveredChangelogJudgedJudgedOpenPullRequest
JudgedPullRequestReadyAwaitingCiAwaitCi
AwaitingCiCiResolved (terminal)RecordedRecordOutcome
AwaitingCiCiResolved (pending)unchangedrejected, no raise
anyany other eventunchangedrejected, no raise
The whole transition table. Every legal path advances and emits one effect. Everything else is a quiet rejection.
src/froot/domain/effects.py ยท 57 lines
src/froot/domain/effects.py57 lines ยท Python
โ‹ฏ 23 lines hidden (lines 1โ€“23)
1"""Effects: data describing what the spine should do next.
2 
3The loop's state machine is pure and performs no I/O. On each transition it
4emits an *effect* โ€” judge the changelog, open the PR, wait on CI, record the
5outcome. The Temporal spine interprets each effect into an activity (or, for
6:class:`AwaitCi`, a durable poll-and-sleep loop) and feeds the resulting event
7back in. Effects are values, so a transition is fully testable without touching
8npm, GitHub, or a model.
9"""
10 
11from __future__ import annotations
12 
13from typing import Annotated, Literal
14 
15from pydantic import Field
16 
17from froot.domain.base import Frozen
18from froot.domain.candidate import PatchCandidate
19from froot.domain.changelog import ChangelogVerdict
20from froot.domain.outcome import LoopOutcome
21from froot.domain.pull_request import PullRequestRef
22 
23 
24class JudgeChangelog(Frozen):
25 """Fetch the candidate's changelog and get the model's typed verdict."""
26 
27 kind: Literal["judge_changelog"] = "judge_changelog"
28 candidate: PatchCandidate
29 
30 
31class OpenPullRequest(Frozen):
32 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
33 
34 kind: Literal["open_pull_request"] = "open_pull_request"
35 candidate: PatchCandidate
36 verdict: ChangelogVerdict
37 
38 
39class AwaitCi(Frozen):
40 """Durably wait on the PR's CI until it resolves or the deadline passes."""
41 
42 kind: Literal["await_ci"] = "await_ci"
43 pr: PullRequestRef
44 
45 
46class RecordOutcome(Frozen):
47 """Record the closed-loop outcome (label the PR, emit run telemetry)."""
48 
49 kind: Literal["record_outcome"] = "record_outcome"
50 outcome: LoopOutcome
51 
โ‹ฏ 6 lines hidden (lines 52โ€“57)
52 
53# What the spine should do after a transition.
54Effect = Annotated[
55 JudgeChangelog | OpenPullRequest | AwaitCi | RecordOutcome,
56 Field(discriminator="kind"),
The seam4๐ŸŽฌ Hexagon + table

Four typed promises

src/froot/ports/protocols.py

This is the membrane. Inward of here everything is pure. Outward, everything is I/O. The membrane itself is four Protocols. The spine depends only on these, never on a concrete client.

๐Ÿงช Fakes (tests)

๐Ÿ”Œ Real adapters (production)

๐Ÿ“œ ports.protocols

โš™๏ธ Spine (activities)

implemented by

implemented by

implemented by

implemented by

swapped for

swapped for

swapped for

swapped for

scan / open_pr / check_ci / record

PackageManager

Forge

ChangelogSource

ModelJudge

NpmPackageManager

GitHubForge

HttpChangelogSource

PydanticAiJudge

FakePackageManager

FakeForge

FakeChangelogSource

FakeJudge

The spine talks to four Protocols. Production wires real adapters behind them; tests wire in-memory fakes. Neither side imports the other.
src/froot/ports/protocols.py ยท 114 lines
src/froot/ports/protocols.py114 lines ยท Python
โ‹ฏ 27 lines hidden (lines 1โ€“27)
1"""Typed Protocols for the impure world the spine talks to.
2 
3Methods are ``async`` so an activity simply awaits a port; an adapter that wraps
4a blocking tool (``npm``, ``git``) runs it off the event loop internally, and
5one backed by an HTTP API uses an async client. The pure core and the spine
6depend on these abstractions; :mod:`froot.adapters` provides the concrete
7implementations and tests pass fakes.
8"""
9 
10from __future__ import annotations
11 
12from typing import TYPE_CHECKING, Protocol
13 
14if TYPE_CHECKING:
15 from pathlib import Path
16 
17 from froot.domain.candidate import AvailableUpgrade, PatchCandidate
18 from froot.domain.changelog import Changelog, ChangelogVerdict
19 from froot.domain.ci import CIStatus
20 from froot.domain.pull_request import (
21 BranchName,
22 PullRequestDraft,
23 PullRequestRef,
24 )
25 from froot.domain.repo import TargetRepo
26 
27 
28class PackageManager(Protocol):
29 """Reads available upgrades and regenerates the manifest + lockfile.
30 
31 The adapter carries the package manager (e.g. ``npm``) but never runs the
32 project's tests or install scripts โ€” lockfile regeneration only, so the
33 worker stays light and no third-party dependency code executes in it.
34 """
35 
36 async def list_upgrades(
37 self, target: TargetRepo, workspace: Path
38 ) -> tuple[AvailableUpgrade, ...]:
39 """Report each outdated dependency and the versions available to it."""
40 ...
41 
42 async def apply_patch_bump(
43 self, candidate: PatchCandidate, workspace: Path
44 ) -> None:
45 """Rewrite the manifest + lockfile in ``workspace`` to the target.
46 
47 Lockfile-only and with install scripts disabled: it resolves and
48 writes the dependency tree but runs no project or dependency code.
49 """
50 ...
51 
โ‹ฏ 63 lines hidden (lines 52โ€“114)
52 
53class Forge(Protocol):
54 """Git + GitHub: checkout, branch/PR, CI status, labels.
55 
56 The verification oracle is the repo's own CI (:meth:`ci_status`); froot
57 never runs tests itself. PR creation is idempotent against the deterministic
58 branch โ€” see :meth:`find_open_pull_request`.
59 """
60 
61 async def checkout(self, target: TargetRepo, workspace: Path) -> None:
62 """Materialize the repo's default branch into ``workspace``."""
63 ...
64 
65 async def push_branch(
66 self, workspace: Path, branch: BranchName, commit_message: str
67 ) -> str:
68 """Commit the workspace changes onto ``branch`` and push it.
69 
70 The workspace's ``origin`` already authenticates against the repo (set
71 up by :meth:`checkout`), so no target is needed here.
72 
73 Returns:
74 The pushed head commit SHA.
75 """
76 ...
77 
78 async def find_open_pull_request(
79 self, target: TargetRepo, branch: BranchName
80 ) -> PullRequestRef | None:
81 """Return the open PR for ``branch`` if one already exists (dedup)."""
82 ...
83 
84 async def open_pull_request(
85 self, target: TargetRepo, draft: PullRequestDraft
86 ) -> PullRequestRef:
87 """Open the PR for an already-pushed branch."""
88 ...
89 
90 async def ci_status(self, target: TargetRepo, head_sha: str) -> CIStatus:
91 """Read the repo's combined CI status for a commit (the oracle)."""
92 ...
93 
94 async def add_labels(
95 self, target: TargetRepo, number: int, labels: tuple[str, ...]
96 ) -> None:
97 """Attach labels to a PR (the human-readable signal-update)."""
98 ...
99 
100 
101class ChangelogSource(Protocol):
102 """Best-effort fetch of a target version's changelog / release notes."""
103 
104 async def fetch(self, candidate: PatchCandidate) -> Changelog | None:
105 """Return the changelog for the candidate's target, or ``None``."""
106 ...
107 
108 
109class ModelJudge(Protocol):
110 """The thin model judgment: is this changelog a clean patch?"""
111 
112 async def judge(self, changelog: Changelog) -> ChangelogVerdict:
113 """Assess a changelog and return a typed verdict."""
114 ...
PortReal adapterFake (tests)
PackageManager โ€” read upgrades, regen the lockfileNpmPackageManagerFakePackageManager
Forge โ€” checkout, PR, CI status, labelsGitHubForgeFakeForge
ChangelogSource โ€” fetch release notesHttpChangelogSourceFakeChangelogSource
ModelJudge โ€” judge a changelogPydanticAiJudgeFakeJudge
Four ports, each with a real implementation and an in-memory fake. The spine names only the left column.
The impure shell5๐ŸŽฌ The two adapters that matter

Where the I/O lives, and where the safety is

src/froot/adapters/npm.pysrc/froot/adapters/github.py

The shell is seven modules of subprocess, git, HTTP, and a model. Two of them carry froot's safety guarantees, so those are the two to read. The pure cores of each (parsing npm output, mapping GitHub checks to a status) are module-level functions, unit-tested with no network.

npm: regenerate the lockfile, run no code

The bump's action is a manifest and lockfile edit. One line makes it safe.

src/froot/adapters/npm.py ยท 162 lines
src/froot/adapters/npm.py162 lines ยท Python
โ‹ฏ 148 lines hidden (lines 1โ€“148)
1"""The npm package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus ``npm view <pkg> versions``, and regenerates the manifest +
5lockfile with ``npm install <pkg>@<target> --package-lock-only
6--ignore-scripts`` โ€” lockfile-only, no install scripts, so no third-party
7dependency code ever runs in the worker (the real install + tests happen in the
8repo's CI, the oracle).
9 
10The installed baseline comes from ``package-lock.json``, *not* ``npm
11outdated``: ``npm outdated``'s ``current`` field is absent without a
12``node_modules`` tree, and froot only does a clone (never an install). The
13parsing is split into pure module functions so it is unit-tested with fixtures,
14away from the subprocess and the network.
15"""
16 
17from __future__ import annotations
18 
19import json
20from typing import TYPE_CHECKING
21 
22from froot.adapters._proc import run_text
23from froot.domain.candidate import AvailableUpgrade
24from froot.domain.version import Version
25from froot.result import Ok
26 
27if TYPE_CHECKING:
28 from pathlib import Path
29 
30 from froot.domain.candidate import PatchCandidate
31 from froot.domain.repo import TargetRepo
32 
33_NODE_MODULES = "node_modules/"
34 
35 
36def parse_direct_dependencies(package_json: str) -> frozenset[str]:
37 """The direct dependency names from a ``package.json`` (deps + devDeps).
38 
39 Only direct dependencies are bumped: ``npm install <pkg>`` on a transitive
40 dependency would promote it to a direct one, which is not a patch.
41 """
42 data = json.loads(package_json)
43 names: set[str] = set()
44 if isinstance(data, dict):
45 for field in ("dependencies", "devDependencies"):
46 section = data.get(field)
47 if isinstance(section, dict):
48 names.update(name for name in section if isinstance(name, str))
49 return frozenset(names)
50 
51 
52def parse_locked_versions(package_lock: str) -> dict[str, str]:
53 """Resolved version per top-level dependency from a ``package-lock.json``.
54 
55 Reads the lockfileVersion 2/3 ``packages`` map (keys
56 ``node_modules/<name>``, skipping nested ``.../node_modules/...`` transitive
57 entries), falling back to the legacy v1 top-level ``dependencies`` map.
58 """
59 data = json.loads(package_lock)
60 if not isinstance(data, dict):
61 return {}
62 versions: dict[str, str] = {}
63 packages = data.get("packages")
64 if isinstance(packages, dict):
65 for key, info in packages.items():
66 if not (isinstance(key, str) and key.startswith(_NODE_MODULES)):
67 continue
68 name = key[len(_NODE_MODULES) :]
69 if "/node_modules/" in name: # a nested (transitive) entry
70 continue
71 version = info.get("version") if isinstance(info, dict) else None
72 if isinstance(version, str):
73 versions[name] = version
74 if versions:
75 return versions
76 legacy = data.get("dependencies") # lockfileVersion 1
77 if isinstance(legacy, dict):
78 for name, info in legacy.items():
79 version = info.get("version") if isinstance(info, dict) else None
80 if isinstance(name, str) and isinstance(version, str):
81 versions[name] = version
82 return versions
83 
84 
85def parse_versions(stdout: str) -> tuple[Version, ...]:
86 """Parse ``npm view <pkg> versions --json`` into domain versions.
87 
88 Accepts a JSON array (the usual case) or a bare JSON string (a single
89 version); empty or non-JSON output (e.g. a failed lookup) yields ``()``.
90 Unparseable entries are dropped.
91 """
92 if not stdout.strip():
93 return ()
94 try:
95 raw = json.loads(stdout)
96 except json.JSONDecodeError:
97 return ()
98 items = raw if isinstance(raw, list) else [raw]
99 versions: list[Version] = []
100 for item in items:
101 if isinstance(item, str):
102 match Version.parse(item):
103 case Ok(version):
104 versions.append(version)
105 case _:
106 continue
107 return tuple(versions)
108 
109 
110class NpmPackageManager:
111 """A :class:`~froot.ports.protocols.PackageManager` backed by ``npm``."""
112 
113 async def list_upgrades(
114 self, target: TargetRepo, workspace: Path
115 ) -> tuple[AvailableUpgrade, ...]:
116 """Report each direct dependency and the versions available to it."""
117 direct = parse_direct_dependencies(
118 (workspace / "package.json").read_text()
119 )
120 lock_path = workspace / "package-lock.json"
121 locked = (
122 parse_locked_versions(lock_path.read_text())
123 if lock_path.exists()
124 else {}
125 )
126 upgrades: list[AvailableUpgrade] = []
127 for name in sorted(direct):
128 current_text = locked.get(name)
129 if current_text is None:
130 continue
131 match Version.parse(current_text):
132 case Ok(current):
133 pass
134 case _:
135 continue
136 _, versions_out = await run_text(
137 "npm", "view", name, "versions", "--json", cwd=workspace
138 )
139 upgrades.append(
140 AvailableUpgrade(
141 package=name,
142 ecosystem=target.ecosystem,
143 current=current,
144 available=parse_versions(versions_out),
145 )
146 )
147 return tuple(upgrades)
148 
149 async def apply_patch_bump(
150 self, candidate: PatchCandidate, workspace: Path
151 ) -> None:
152 """Rewrite the manifest + lockfile to the target (lockfile-only)."""
153 code, out = await run_text(
154 "npm",
155 "install",
156 f"{candidate.package}@{candidate.target}",
157 "--package-lock-only",
158 "--ignore-scripts",
159 cwd=workspace,
160 )
161 if code != 0:
162 raise RuntimeError(f"npm install failed ({code}): {out}")

GitHub: read the oracle, open idempotently, fail safe on auth

The CI reading is a pure function over typed check rows, so it tests apart from the network.

src/froot/adapters/github.py ยท 276 lines
src/froot/adapters/github.py276 lines ยท Python
โ‹ฏ 68 lines hidden (lines 1โ€“68)
1"""The GitHub forge adapter: git (checkout/push) + the GitHub REST API.
2 
3Backs :class:`~froot.ports.protocols.Forge`. Checkout and branch push go
4through ``git``; pull requests, CI status, and labels go through the REST API
5(httpx). The verification oracle is the repo's own CI โ€” :meth:`ci_status` reads
6it, froot never runs tests. PR creation is idempotent against the deterministic
7head branch (:meth:`find_open_pull_request`), so a re-run never double-opens.
8 
9The CI mapping (:func:`ci_status_from_checks`) is a pure function over typed
10check rows, unit-tested apart from the network; the GitHub JSON shapes are
11read at the boundary as untyped payloads and coerced into the domain.
12"""
13 
14from __future__ import annotations
15 
16from dataclasses import dataclass
17from typing import TYPE_CHECKING, Any, final
18 
19import httpx
20from temporalio.exceptions import ApplicationError
21 
22from froot.config.settings import GitHubSettings
23from froot.domain.ci import (
24 CIAbsent,
25 CIFailed,
26 CIPassed,
27 CIPending,
28 CIStatus,
30from froot.domain.pull_request import BranchName, PullRequestRef
31 
32if TYPE_CHECKING:
33 from pathlib import Path
34 
35 from froot.domain.pull_request import PullRequestDraft
36 from froot.domain.repo import TargetRepo
37 
38from froot.adapters._proc import run_text
39 
40_API = "https://api.github.com"
41_API_VERSION = "2022-11-28"
42_TIMEOUT = 30.0
43_COMMITTER_NAME = "froot"
44_COMMITTER_EMAIL = "froot@users.noreply.github.com"
45 
46# Check-run conclusions that mean the change is not safe to merge.
47_BAD_CONCLUSIONS = frozenset(
48 {
49 "failure",
50 "timed_out",
51 "cancelled",
52 "action_required",
53 "startup_failure",
54 "stale",
55 }
57 
58 
59@final
60@dataclass(frozen=True, slots=True)
61class CheckRow:
62 """One GitHub check run, reduced to what the CI mapping needs."""
63 
64 name: str
65 status: str
66 conclusion: str | None
67 
68 
69def ci_status_from_checks(
70 checks: tuple[CheckRow, ...], combined_state: str | None
71) -> CIStatus:
72 """Map GitHub check rows + the combined commit status to a CI status.
73 
74 Args:
75 checks: The commit's check runs (GitHub Checks API).
76 combined_state: The legacy combined commit status (``success`` /
77 ``failure`` / ``pending``), or ``None`` when no statuses exist.
78 
79 Returns:
80 ``CIAbsent`` when nothing reports, ``CIPending`` while anything is
81 unresolved, ``CIFailed`` (with the failing check names) on any bad
82 conclusion or a failed combined status, else ``CIPassed``.
83 """
84 if not checks and combined_state is None:
85 return CIAbsent()
86 unresolved = combined_state == "pending" or any(
87 row.status != "completed" for row in checks
88 )
89 if unresolved:
90 return CIPending()
91 failing = tuple(
92 row.name for row in checks if row.conclusion in _BAD_CONCLUSIONS
93 )
94 if failing or combined_state == "failure":
95 return CIFailed(failing=failing)
96 return CIPassed()
โ‹ฏ 180 lines hidden (lines 97โ€“276)
97 
98 
99def _token() -> str:
100 token = GitHubSettings().github_token
101 if token is None:
102 # A missing token is a permanent misconfiguration, not a transient
103 # fault โ€” fail the activity fast instead of retrying forever.
104 raise ApplicationError(
105 "FROOT_GITHUB_TOKEN is required", non_retryable=True
106 )
107 return token.get_secret_value()
108 
109 
110def _auth_remote(target: TargetRepo) -> str:
111 return (
112 f"https://x-access-token:{_token()}@github.com/{target.repo.slug}.git"
113 )
114 
115 
116def _client() -> httpx.AsyncClient:
117 return httpx.AsyncClient(
118 base_url=_API,
119 timeout=_TIMEOUT,
120 headers={
121 "Authorization": f"Bearer {_token()}",
122 "Accept": "application/vnd.github+json",
123 "X-GitHub-Api-Version": _API_VERSION,
124 },
125 )
126 
127 
128def _raise_for_status(response: httpx.Response) -> None:
129 """Raise on error; 401/403 is a permanent (non-retryable) auth fault."""
130 if response.status_code in (401, 403):
131 raise ApplicationError(
132 f"GitHub auth failed ({response.status_code})", non_retryable=True
133 )
134 response.raise_for_status()
135 
136 
137def _pull_request_ref(payload: Any) -> PullRequestRef:
138 """Coerce a GitHub PR JSON payload into a domain ref (boundary)."""
139 head = payload["head"]
140 return PullRequestRef(
141 number=int(payload["number"]),
142 url=str(payload["html_url"]),
143 branch=BranchName(value=str(head["ref"])),
144 head_sha=str(head["sha"]),
145 )
146 
147 
148@final
149class GitHubForge:
150 """A :class:`~froot.ports.protocols.Forge` over git + the GitHub API."""
151 
152 async def checkout(self, target: TargetRepo, workspace: Path) -> None:
153 """Shallow-clone the repo's default branch into ``workspace``."""
154 code, out = await run_text(
155 "git",
156 "clone",
157 "--depth",
158 "1",
159 "--branch",
160 target.default_branch,
161 _auth_remote(target),
162 ".",
163 cwd=workspace,
164 )
165 if code != 0:
166 raise RuntimeError(f"git clone failed ({code}): {out}")
167 
168 async def push_branch(
169 self, workspace: Path, branch: BranchName, commit_message: str
170 ) -> str:
171 """Commit the workspace changes onto ``branch`` and push; return SHA."""
172 steps: tuple[tuple[str, ...], ...] = (
173 ("git", "checkout", "-b", branch.value),
174 ("git", "add", "-A"),
175 (
176 "git",
177 "-c",
178 f"user.name={_COMMITTER_NAME}",
179 "-c",
180 f"user.email={_COMMITTER_EMAIL}",
181 "commit",
182 "-m",
183 commit_message,
184 ),
185 ("git", "push", "-u", "origin", branch.value),
186 )
187 for step in steps:
188 code, out = await run_text(*step, cwd=workspace)
189 if code != 0:
190 raise RuntimeError(f"{step[0:2]} failed ({code}): {out}")
191 code, sha = await run_text("git", "rev-parse", "HEAD", cwd=workspace)
192 if code != 0:
193 raise RuntimeError(f"git rev-parse failed ({code})")
194 return sha.strip()
195 
196 async def find_open_pull_request(
197 self, target: TargetRepo, branch: BranchName
198 ) -> PullRequestRef | None:
199 """Return the open PR for ``branch`` if one already exists (dedup)."""
200 async with _client() as client:
201 resp = await client.get(
202 f"/repos/{target.repo.slug}/pulls",
203 params={
204 "head": f"{target.repo.owner}:{branch.value}",
205 "state": "open",
206 },
207 )
208 _raise_for_status(resp)
209 payloads = resp.json()
210 if isinstance(payloads, list) and payloads:
211 return _pull_request_ref(payloads[0])
212 return None
213 
214 async def open_pull_request(
215 self, target: TargetRepo, draft: PullRequestDraft
216 ) -> PullRequestRef:
217 """Open the PR for an already-pushed branch (idempotent on conflict)."""
218 async with _client() as client:
219 resp = await client.post(
220 f"/repos/{target.repo.slug}/pulls",
221 json={
222 "title": draft.title,
223 "head": draft.branch.value,
224 "base": draft.base,
225 "body": draft.body,
226 },
227 )
228 if resp.status_code == 422:
229 existing = await self.find_open_pull_request(target, draft.branch)
230 if existing is not None:
231 return existing
232 _raise_for_status(resp)
233 return _pull_request_ref(resp.json())
234 
235 async def ci_status(self, target: TargetRepo, head_sha: str) -> CIStatus:
236 """Read the repo's combined CI status for a commit (the oracle)."""
237 async with _client() as client:
238 checks_resp = await client.get(
239 f"/repos/{target.repo.slug}/commits/{head_sha}/check-runs",
240 params={"per_page": 100},
241 )
242 status_resp = await client.get(
243 f"/repos/{target.repo.slug}/commits/{head_sha}/status"
244 )
245 _raise_for_status(checks_resp)
246 _raise_for_status(status_resp)
247 rows = tuple(
248 CheckRow(
249 name=str(run["name"]),
250 status=str(run["status"]),
251 conclusion=(
252 str(run["conclusion"])
253 if run.get("conclusion") is not None
254 else None
255 ),
256 )
257 for run in checks_resp.json().get("check_runs", [])
258 )
259 status_json = status_resp.json()
260 combined = (
261 str(status_json["state"])
262 if int(status_json.get("total_count", 0)) > 0
263 else None
264 )
265 return ci_status_from_checks(rows, combined)
266 
267 async def add_labels(
268 self, target: TargetRepo, number: int, labels: tuple[str, ...]
269 ) -> None:
270 """Attach labels to a PR (the human-readable signal-update)."""
271 async with _client() as client:
272 resp = await client.post(
273 f"/repos/{target.repo.slug}/issues/{number}/labels",
274 json={"labels": list(labels)},
275 )
276 _raise_for_status(resp)
check runscombined statusresult
nonenoneCIAbsent (nothing to verify)
any not completedโ€”CIPending (keep waiting)
any bad conclusionโ€”CIFailed (with failing names)
all completed and goodsuccess / noneCIPassed
The CI mapping unifies the modern Checks API with the legacy combined status. Bad conclusions include failure, timed_out, cancelled, action_required, startup_failure, stale.
The impure shell6๐ŸŽฌ Spine-heavy, model-thin

The one model call

src/froot/adapters/model_judge.py

Here is froot's entire use of an LLM. The deterministic spine decides when and whether to act. The model answers one typed question: does this patch's changelog read clean, or does it hint at hidden behavior change? Even that answer only frames the PR for the human. It never gates the bump.

src/froot/adapters/model_judge.py ยท 88 lines
src/froot/adapters/model_judge.py88 lines ยท Python
โ‹ฏ 30 lines hidden (lines 1โ€“30)
1"""The thin model judgment: is this changelog a clean patch?
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 model is injected, so tests run it offline with a
8``TestModel`` / ``FunctionModel``; the mapping is a pure function, tested apart
9from any model.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING, Literal, assert_never
15 
16from pydantic import BaseModel, Field
17from pydantic_ai import Agent
18 
19from froot.adapters.model import build_model
20from froot.domain.changelog import (
21 CleanVerdict,
22 RiskyVerdict,
23 UnknownVerdict,
25 
26if TYPE_CHECKING:
27 from pydantic_ai.models import Model
28 
29 from froot.domain.changelog import Changelog, ChangelogVerdict
30 
31_SYSTEM_PROMPT = (
32 "You assess the changelog of a dependency's PATCH-level upgrade for a "
33 "code-maintenance bot. The bot proposes the bump either way; your job is "
34 "only to frame the risk for the human reviewer.\n"
35 "Return one verdict:\n"
36 "- clean: the notes describe only fixes / docs / internal changes with no "
37 "behavioral or API impact.\n"
38 "- risky: the notes hint at behavior change, deprecations, or anything a "
39 "reviewer should look at closely; list the concerns.\n"
40 "- unknown: the notes are empty or uninformative.\n"
41 "Quote-or-omit: base 'risky' concerns on what the text actually says; do "
42 "not speculate. Keep the rationale to one or two sentences."
โ‹ฏ 10 lines hidden (lines 44โ€“53)
44 
45 
46class _Assessment(BaseModel):
47 """The model's structured output, mapped to a domain verdict."""
48 
49 verdict: Literal["clean", "risky", "unknown"]
50 rationale: str
51 concerns: list[str] = Field(default_factory=list)
52 
53 
54def assessment_to_verdict(assessment: _Assessment) -> ChangelogVerdict:
55 """Map the model's structured assessment to a domain verdict (pure)."""
56 match assessment.verdict:
57 case "clean":
58 return CleanVerdict(rationale=assessment.rationale)
59 case "risky":
60 return RiskyVerdict(
61 rationale=assessment.rationale,
62 concerns=tuple(assessment.concerns),
63 )
64 case "unknown":
65 return UnknownVerdict(rationale=assessment.rationale)
66 assert_never(assessment.verdict)
โ‹ฏ 22 lines hidden (lines 67โ€“88)
67 
68 
69class PydanticAiJudge:
70 """A :class:`~froot.ports.protocols.ModelJudge` backed by Pydantic AI."""
71 
72 def __init__(self, model: Model | None = None) -> None:
73 """Build the agent; ``model`` defaults to the configured Ollama."""
74 self._agent: Agent[None, _Assessment] = Agent(
75 model or build_model(),
76 output_type=_Assessment,
77 system_prompt=_SYSTEM_PROMPT,
78 )
79 
80 async def judge(self, changelog: Changelog) -> ChangelogVerdict:
81 """Assess a changelog and return a typed verdict."""
82 prompt = (
83 f"Package: {changelog.package}\n"
84 f"Target version: {changelog.version}\n"
85 f"Changelog:\n{changelog.text}"
86 )
87 result = await self._agent.run(prompt)
88 return assessment_to_verdict(result.output)

The agent's output type is a small Pydantic model, so Pydantic AI constrains the model to return that exact shape. The pure assessment_to_verdict then maps it to a domain verdict and ends in assert_never, so a fourth verdict kind would fail to type-check until handled. The model is injected, so the test runs it offline with a TestModel.

The durable spine7๐ŸŽฌ Sequence + the durable wait

Driving the pure machine, durably

src/froot/workflow/bump_workflow.pysrc/froot/workflow/scan_workflow.py

This is why froot is on Temporal. The spine is thin, because the logic already lives in the pure core. What the spine adds is durability: a self-triggering schedule, a CI wait that survives an hour without holding a process open, and a recorded outcome. The workflows use only pure state and Temporal's own APIs, so they replay deterministically.

The bump workflow: a loop around the state machine

๐Ÿ™ repo CIโš™๏ธ activities๐Ÿ” BumpWorkflow._execute๐Ÿงฎ state_machine (pure)๐Ÿ™ repo CIโš™๏ธ activities๐Ÿ” BumpWorkflow._execute๐Ÿงฎ state_machine (pure)transition = start(candidate)loop[durable poll until terminal or 1h deadline]effect JudgeChangelog1judge_changelog(candidate)2ChangelogVerdict3advance(Discovered, ChangelogJudged)4effect OpenPullRequest5open_pull_request(...)6PullRequestRef7advance(Judged, PullRequestReady)8effect AwaitCi9check_ci(head_sha)10read combined status11CIPending โ†’ sleep(1m) โฐ12CIPassed / CIFailed13advance(AwaitingCi, CiResolved)14effect RecordOutcome15record_outcome(...) โœ… label + log16advance โ†’ Recorded โˆŽ17
One bump. Each pure effect becomes an activity call whose result becomes the next event fed back to advance(). The AwaitCi effect expands into the durable poll-and-sleep loop.
src/froot/workflow/bump_workflow.py ยท 138 lines
src/froot/workflow/bump_workflow.py138 lines ยท Python
โ‹ฏ 62 lines hidden (lines 1โ€“62)
1"""The per-bump lifecycle workflow โ€” a thin driver over the pure core.
2 
3One durable workflow per (repo, package, target), keyed by the deterministic
4:func:`~froot.policy.naming.bump_workflow_id`, so a bump is proposed at most
5once. It loops: take the pure state machine's next effect, run it as an activity
6(or, for the CI wait, a durable poll-and-sleep), feed the resulting event back
7to :func:`~froot.policy.state_machine.advance`, and repeat until ``Recorded``.
8All nondeterminism is in the activities; the workflow uses only pure state and
9Temporal's own time APIs, so replay is deterministic.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING, assert_never
15 
16from temporalio import workflow
17from temporalio.exceptions import ApplicationError
18 
19with workflow.unsafe.imports_passed_through():
20 from froot.domain.ci import CIStatus, CITimedOut, is_terminal
21 from froot.domain.effects import (
22 AwaitCi,
23 Effect,
24 JudgeChangelog,
25 OpenPullRequest,
26 RecordOutcome,
27 )
28 from froot.domain.events import (
29 ChangelogJudged,
30 CiResolved,
31 LoopEvent,
32 OutcomeRecorded,
33 PullRequestReady,
34 )
35 from froot.domain.outcome import LoopOutcome
36 from froot.domain.state import Recorded
37 from froot.policy.state_machine import TransitionKind, advance, start
38 from froot.workflow import activities
39 from froot.workflow.constants import (
40 ACTIVITY_TIMEOUT,
41 CI_CHECK_TIMEOUT,
42 CI_POLL_INTERVAL,
43 CI_WAIT_DEADLINE,
44 )
45 from froot.workflow.types import (
46 BumpParams,
47 CiCheckInput,
48 OpenPrInput,
49 RecordInput,
50 )
51 
52if TYPE_CHECKING:
53 # Used only in the (non-workflow-decorated) helper signatures, so these are
54 # type-only โ€” unlike the run() signature, Temporal does not evaluate them.
55 from froot.domain.pull_request import PullRequestRef
56 from froot.domain.repo import TargetRepo
57 
58 
59@workflow.defn
60class BumpWorkflow:
61 """The durable loop for a single dependency patch bump."""
62 
63 @workflow.run
64 async def run(self, params: BumpParams) -> LoopOutcome:
65 """Drive the pure state machine to a recorded outcome."""
66 transition = start(params.candidate)
67 while transition.effects:
68 state = transition.next
69 if len(transition.effects) != 1:
70 raise ApplicationError(
71 f"non-linear transition ({len(transition.effects)} "
72 "effects)",
73 non_retryable=True,
74 )
75 event = await self._execute(params.target, transition.effects[0])
76 transition = advance(state, event)
77 if transition.kind is TransitionKind.REJECTED:
78 raise ApplicationError(
79 f"rejected transition: {transition.reason}",
80 non_retryable=True,
81 )
82 final = transition.next
83 if not isinstance(final, Recorded):
84 raise ApplicationError(
85 f"loop ended in non-terminal state: {final.kind}",
86 non_retryable=True,
87 )
88 return final.outcome
โ‹ฏ 34 lines hidden (lines 89โ€“122)
89 
90 async def _execute(self, target: TargetRepo, effect: Effect) -> LoopEvent:
91 """Interpret one effect into an activity (or a durable CI wait)."""
92 match effect:
93 case JudgeChangelog():
94 verdict = await workflow.execute_activity(
95 activities.judge_changelog,
96 effect.candidate,
97 start_to_close_timeout=ACTIVITY_TIMEOUT,
98 )
99 return ChangelogJudged(verdict=verdict)
100 case OpenPullRequest():
101 pr = await workflow.execute_activity(
102 activities.open_pull_request,
103 OpenPrInput(
104 target=target,
105 candidate=effect.candidate,
106 verdict=effect.verdict,
107 ),
108 start_to_close_timeout=ACTIVITY_TIMEOUT,
109 )
110 return PullRequestReady(pr=pr)
111 case AwaitCi():
112 status = await self._await_ci(target, effect.pr)
113 return CiResolved(status=status)
114 case RecordOutcome():
115 await workflow.execute_activity(
116 activities.record_outcome,
117 RecordInput(target=target, outcome=effect.outcome),
118 start_to_close_timeout=CI_CHECK_TIMEOUT,
119 )
120 return OutcomeRecorded()
121 assert_never(effect)
122 
123 async def _await_ci(
124 self, target: TargetRepo, pr: PullRequestRef
125 ) -> CIStatus:
126 """Durably poll the repo's CI until it resolves or the deadline."""
127 deadline = workflow.now() + CI_WAIT_DEADLINE
128 while True:
129 status = await workflow.execute_activity(
130 activities.check_ci,
131 CiCheckInput(target=target, head_sha=pr.head_sha),
132 start_to_close_timeout=CI_CHECK_TIMEOUT,
133 )
134 if is_terminal(status):
135 return status
136 if workflow.now() >= deadline:
137 return CITimedOut()
138 await workflow.sleep(CI_POLL_INTERVAL)

The scan workflow: a self-rescheduling timer

One long-lived workflow per repo. Each tick scans, dispatches a bump loop per candidate, sleeps, and restarts.

src/froot/workflow/scan_workflow.py ยท 56 lines
src/froot/workflow/scan_workflow.py56 lines ยท Python
โ‹ฏ 30 lines hidden (lines 1โ€“30)
1"""The self-scheduling scan loop โ€” froot's durable trigger (per repo).
2 
3One long-lived workflow per target repo. Each tick checks out the repo, selects
4the patch candidates, and dispatches a bump loop per candidate (idempotent, so
5re-dispatch is a no-op); then it sleeps and continues-as-new, keeping history
6bounded to one tick. There is no stored seen-set โ€” the outstanding work is
7re-derived from the repo each tick (derive, never store), and the per-bump
8workflow id makes re-proposing an already-handled bump a no-op.
9 
10A one-shot run (``continuous=False``, the default) performs a single tick and
11returns; production starts it once with ``continuous=True`` and it runs forever.
12"""
13 
14from __future__ import annotations
15 
16from datetime import timedelta
17 
18from temporalio import workflow
19 
20with workflow.unsafe.imports_passed_through():
21 from froot.workflow import activities
22 from froot.workflow.constants import ACTIVITY_TIMEOUT, DISPATCH_TIMEOUT
23 from froot.workflow.types import DispatchInput, ScanParams, ScanResult
24 
25 
26@workflow.defn
27class ScanWorkflow:
28 """The durable dependency-scan loop (one tick per continue-as-new)."""
29 
30 @workflow.run
31 async def run(self, params: ScanParams) -> ScanResult:
32 """Scan for patch candidates, dispatch each, then loop or return."""
33 candidates = await workflow.execute_activity(
34 activities.scan_candidates,
35 params.target,
36 start_to_close_timeout=ACTIVITY_TIMEOUT,
37 )
38 for candidate in candidates:
39 await workflow.execute_activity(
40 activities.dispatch_bump,
41 DispatchInput(target=params.target, candidate=candidate),
42 start_to_close_timeout=DISPATCH_TIMEOUT,
43 )
44 result = ScanResult(found=len(candidates), dispatched=len(candidates))
45 if not params.continuous:
46 return result
47 # Durable loop: sleep, then restart fresh. continue_as_new raises, so
48 # nothing runs after it and history stays bounded to one tick.
49 await workflow.sleep(timedelta(seconds=params.interval_seconds))
50 workflow.continue_as_new(
51 ScanParams(
52 target=params.target,
53 interval_seconds=params.interval_seconds,
54 continuous=True,
55 )
56 )
Configuration8๐ŸŽฌ Secret handling

All the knobs, none of the secrets

src/froot/config/settings.py

Every difference between a laptop and the cluster is one of five frozen settings models, each reading a small slice of the environment. Nothing secret lives in the repo. The token gets special handling.

src/froot/config/settings.py ยท 125 lines
src/froot/config/settings.py125 lines ยท Python
โ‹ฏ 83 lines hidden (lines 1โ€“83)
1"""All deployment config, as pydantic-settings models (env or ``.env``), frozen.
2 
3* :class:`Settings` (``FROOT_*``) โ€” loop config: repositories and scan interval.
4* :class:`TemporalSettings` (``TEMPORAL_*``) โ€” connection: host / namespace /
5 task queue, shared by the worker, the scan starter, and the activity client.
6* :class:`GitHubSettings` โ€” the API token (``FROOT_GITHUB_TOKEN``) as a
7 :class:`~pydantic.SecretStr`, so it is masked in ``repr``, logs, and
8 tracebacks and cannot leak accidentally.
9* :class:`ModelSettings` (``FROOT_OLLAMA_MODEL`` / ``FROOT_OLLAMA_URL``) โ€” the
10 changelog-judge model endpoint.
11* :class:`TelemetrySettings` (``FROOT_OTEL``) โ€” observability toggle.
12 
13Each consumer builds the small model it needs at its point of use; nothing
14secret lives in the repo. ``repos`` is ``NoDecode`` so ``FROOT_REPOS`` is a
15comma-separated list of ``owner/name`` slugs rather than JSON.
16"""
17 
18from __future__ import annotations
19 
20from typing import Annotated
21 
22from pydantic import Field, SecretStr, field_validator
23from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
24 
25from froot.domain.repo import RepoRef, TargetRepo
26from froot.result import Ok
27 
28_DEFAULT_SCAN_INTERVAL_SECONDS = 86_400
29 
30 
31class Settings(BaseSettings):
32 """Non-secret worker config from ``FROOT_*`` (env or ``.env``)."""
33 
34 model_config = SettingsConfigDict(
35 env_prefix="FROOT_",
36 env_file=".env",
37 extra="ignore",
38 frozen=True,
39 )
40 
41 repos: Annotated[tuple[TargetRepo, ...], NoDecode] = Field(min_length=1)
42 scan_interval_seconds: int = Field(
43 default=_DEFAULT_SCAN_INTERVAL_SECONDS, gt=0
44 )
45 
46 @field_validator("repos", mode="before")
47 @classmethod
48 def _parse_repos(cls, value: object) -> object:
49 """Accept ``FROOT_REPOS`` as a comma-separated ``owner/name`` list."""
50 if not isinstance(value, str):
51 return value
52 targets: list[TargetRepo] = []
53 for raw in value.split(","):
54 slug = raw.strip()
55 if not slug:
56 continue
57 match RepoRef.parse(slug):
58 case Ok(ref):
59 targets.append(TargetRepo(repo=ref))
60 case _:
61 raise ValueError(f"invalid repo slug: {slug!r}")
62 return tuple(targets)
63 
64 
65class TemporalSettings(BaseSettings):
66 """Temporal connection config from ``TEMPORAL_*`` (env or ``.env``).
67 
68 The same image runs anywhere by configuring these; the in-cluster
69 deployment sets them to the cluster's frontend, namespace, and queue.
70 """
71 
72 model_config = SettingsConfigDict(
73 env_prefix="TEMPORAL_",
74 env_file=".env",
75 extra="ignore",
76 frozen=True,
77 )
78 
79 host: str = Field(default="localhost:7233", min_length=1)
80 namespace: str = Field(default="default", min_length=1)
81 task_queue: str = Field(default="froot", min_length=1)
82 
83 
84class GitHubSettings(BaseSettings):
85 """GitHub credentials, from ``FROOT_GITHUB_TOKEN``.
86 
87 The token is a :class:`~pydantic.SecretStr`, so it is masked in ``repr``,
88 logs, and tracebacks and cannot leak accidentally; call
89 ``github_token.get_secret_value()`` only where the real value is sent.
90 """
91 
92 model_config = SettingsConfigDict(
93 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
94 )
95 
96 github_token: SecretStr | None = None
97 
โ‹ฏ 28 lines hidden (lines 98โ€“125)
98 
99class ModelSettings(BaseSettings):
100 """The changelog-judge model endpoint (a local Ollama by default)."""
101 
102 model_config = SettingsConfigDict(
103 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
104 )
105 
106 ollama_model: str = Field(default="gemma4:e4b", min_length=1)
107 ollama_url: str = Field(default="http://localhost:11434/v1", min_length=1)
108 
109 
110class TelemetrySettings(BaseSettings):
111 """OpenTelemetry toggle โ€” off unless ``FROOT_OTEL`` is truthy."""
112 
113 model_config = SettingsConfigDict(
114 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
115 )
116 
117 otel: bool = False
118 
119 @field_validator("otel", mode="before")
120 @classmethod
121 def _blank_is_off(cls, value: object) -> object:
122 """Treat an empty/whitespace ``FROOT_OTEL`` as off, not an error."""
123 if isinstance(value, str) and not value.strip():
124 return False
125 return value

Two more touches: FROOT_REPOS is read as a comma-separated list of owner/name slugs rather than JSON, each parsed through a boundary parser that raises on a bad slug. And the settings models are frozen, so config is read once and cannot mutate mid-run.

How it's proven9๐ŸŽฌ The cleverest test

An hour-long wait, tested in milliseconds

tests/test_bump_workflow.py

Because the core is pure and the seams are ports, the suite is a clean pyramid: a broad base of pure-unit and property tests, a middle of activities over fakes, and a thin top of real-spine integration tests. The top tier is the interesting one. It runs the actual workflows, with only the activities mocked, and it does not wait real time.

tests/test_bump_workflow.py ยท 122 lines
tests/test_bump_workflow.py122 lines ยท Python
โ‹ฏ 43 lines hidden (lines 1โ€“43)
1"""Integration tests for the bump workflow on a time-skipping test server.
2 
3The activities are mocked (by matching activity name), so this exercises the
4real spine: the driver loop, the effect interpretation, and the durable CI wait
5(whose ``workflow.sleep`` the time-skipping server fast-forwards).
6"""
7 
8from __future__ import annotations
9 
10from collections.abc import Callable
11from typing import Any
12 
13from temporalio import activity
14from temporalio.client import Client
15from temporalio.testing import WorkflowEnvironment
16from temporalio.worker import Worker
17 
18from froot.domain.candidate import PatchCandidate
19from froot.domain.changelog import ChangelogVerdict, CleanVerdict
20from froot.domain.ci import (
21 CIFailed,
22 CIPassed,
23 CIPending,
24 CIStatus,
25 CITimedOut,
27from froot.domain.outcome import LoopOutcome
28from froot.domain.pull_request import PullRequestRef
29from froot.workflow.bump_workflow import BumpWorkflow
30from froot.workflow.runtime import DATA_CONVERTER
31from froot.workflow.types import (
32 BumpParams,
33 CiCheckInput,
34 OpenPrInput,
35 RecordInput,
37from tests.support import make_candidate, make_pr, make_repo
38 
39_TASK_QUEUE = "froot-test"
40# A scripted CI reply sequence the mock pops through (then falls back to green).
41_ci_replies: list[CIStatus] = []
42 
43 
44@activity.defn(name="judge_changelog")
45async def _mock_judge(candidate: PatchCandidate) -> ChangelogVerdict:
46 return CleanVerdict(rationale="patch only")
47 
48 
49@activity.defn(name="open_pull_request")
50async def _mock_open_pr(params: OpenPrInput) -> PullRequestRef:
51 return make_pr(number=7)
52 
53 
54@activity.defn(name="check_ci")
55async def _mock_check_ci(params: CiCheckInput) -> CIStatus:
56 return _ci_replies.pop(0) if _ci_replies else CIPassed()
57 
58 
59@activity.defn(name="record_outcome")
60async def _mock_record(params: RecordInput) -> None:
61 return None
62 
โ‹ฏ 32 lines hidden (lines 63โ€“94)
63 
64_MOCKS: list[Callable[..., Any]] = [
65 _mock_judge,
66 _mock_open_pr,
67 _mock_check_ci,
68 _mock_record,
70 
71 
72async def _pydantic_client(env: WorkflowEnvironment) -> Client:
73 config = env.client.config()
74 config["data_converter"] = DATA_CONVERTER
75 return Client(**config)
76 
77 
78async def _run_bump() -> LoopOutcome:
79 async with await WorkflowEnvironment.start_time_skipping() as env:
80 client = await _pydantic_client(env)
81 async with Worker(
82 client,
83 task_queue=_TASK_QUEUE,
84 workflows=[BumpWorkflow],
85 activities=_MOCKS,
86 ):
87 return await client.execute_workflow(
88 BumpWorkflow.run,
89 BumpParams(target=make_repo(), candidate=make_candidate()),
90 id="bump-test",
91 task_queue=_TASK_QUEUE,
92 )
93 
94 
95async def test_happy_path_green():
96 _ci_replies.clear()
97 outcome = await _run_bump()
98 assert outcome.pr.number == 7
99 assert outcome.ci_passed
100 assert isinstance(outcome.ci, CIPassed)
101 
102 
103async def test_ci_failed_records_failure_and_does_not_merge():
104 _ci_replies.clear()
105 _ci_replies.append(CIFailed(failing=("build",)))
106 outcome = await _run_bump()
107 assert not outcome.ci_passed
108 assert isinstance(outcome.ci, CIFailed)
109 
110 
111async def test_ci_pending_then_pass_waits_durably():
112 _ci_replies.clear()
113 _ci_replies.extend([CIPending(), CIPending(), CIPassed()])
114 outcome = await _run_bump()
115 assert outcome.ci_passed
116 
117 
118async def test_ci_timeout_when_never_resolves():
119 _ci_replies.clear()
120 _ci_replies.extend([CIPending()] * 100)
121 outcome = await _run_bump()
122 assert isinstance(outcome.ci, CITimedOut)

A scripted _ci_replies queue drives each scenario. The four tests cover the loop's four terminal shapes: green, red, pending-then-green, and timeout. The same property that makes the production wait free, time read only through Temporal's API, is what lets the test server lie about the clock without the workflow noticing.

Build &amp; deploy10๐ŸŽฌ Walkthrough + topology

The gate, the image, and one worker pointed outward

pyproject.tomlDockerfileinfra/k8s/froot/manifests/10-worker.yaml

The type guarantees in ยง2 are only real if a checker enforces them. pyproject turns the screws: strict mypy with the Pydantic plugin, plus a ruff config that knows the one trap.

pyproject.toml ยท 158 lines
pyproject.toml158 lines ยท TOML
โ‹ฏ 55 lines hidden (lines 1โ€“55)
1[project]
2name = "froot"
3version = "0.1.0"
4description = "Durable, self-scheduled code-maintenance loops on Temporal, pointed at any repo. First loop: dependency-patch (Pydantic AI on Temporal)."
5readme = "README.md"
6# Pinned to a uv-managed 3.13 (see .python-version): the local Homebrew 3.14
7# ships a broken pyexpat, and Temporal, Pydantic AI, and Pydantic v2 all support
8# 3.13. Mirrors the ynab-agent chassis this is modeled on.
9requires-python = ">=3.13,<3.14"
10dependencies = [
11 "pydantic>=2.10",
12 "pydantic-settings>=2.4",
13 "temporalio>=1.27.2",
15 
16[project.optional-dependencies]
17# Dev tooling: linters, type-checker, test runner, property-based testing.
18dev = [
19 "pytest>=8.0.0",
20 "pytest-asyncio>=0.24.0",
21 "pytest-cov>=6.0.0",
22 "hypothesis>=6.100.0",
23 "mypy>=1.13.0",
24 "ruff>=0.8.0",
26# The thin model judgment (is this changelog a clean patch?): a Pydantic AI agent
27# over a local Ollama via its OpenAI-compatible /v1, mirroring ynab-agent. Kept
28# out of the default env so the pure core never pulls the model stack into a
29# Temporal workflow sandbox.
30ai = [
31 "pydantic-ai-slim[openai]>=0.0.20",
33# The GitHub adapter (open/find PRs, read CI status, set labels) โ€” a thin httpx
34# client behind a protocol; the token comes from the environment.
35github = [
36 "httpx>=0.27",
38# OpenTelemetry: distributed traces + Temporal SDK metrics for the worker
39# (the run-telemetry half of "derive, never store"). Traces export OTLP/HTTP
40# (no grpcio wheel); SDK metrics export via Temporal's own Rust OTLP exporter.
41otel = [
42 "temporalio[opentelemetry]>=1.27.2",
43 "opentelemetry-sdk>=1.42,<2",
44 "opentelemetry-exporter-otlp-proto-http>=1.42,<2",
45 "opentelemetry-instrumentation-httpx>=0.63b0,<1",
47 
48[build-system]
49requires = ["hatchling"]
50build-backend = "hatchling.build"
51 
52[tool.hatch.build.targets.wheel]
53packages = ["src/froot"]
54 
55# โ”€โ”€ Type checking: strict, the DDD safety net โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56[tool.mypy]
57python_version = "3.13"
58strict = true
59warn_return_any = true
60warn_unused_configs = true
61disallow_untyped_defs = true
62disallow_incomplete_defs = true
63check_untyped_defs = true
64disallow_untyped_decorators = true
65no_implicit_optional = true
66warn_redundant_casts = true
67warn_unused_ignores = true
68warn_no_return = true
69warn_unreachable = true
70show_error_codes = true
71# The pydantic plugin teaches mypy about model fields, frozen-ness, and smart
72# constructors โ€” load-bearing for "make illegal states unrepresentable".
73plugins = ["pydantic.mypy"]
74mypy_path = "src"
75files = ["src", "tests"]
โ‹ฏ 46 lines hidden (lines 76โ€“121)
76 
77[tool.pydantic-mypy]
78init_forbid_extra = true
79init_typed = true
80warn_required_dynamic_aliases = true
81 
82# Source stays fully strict; tests need not annotate every function's return
83# (boilerplate), but their bodies are still type-checked (check_untyped_defs is
84# on) and the fakes/builders are fully typed.
85[[tool.mypy.overrides]]
86module = "tests.*"
87disallow_untyped_defs = false
88disallow_incomplete_defs = false
89 
90# โ”€โ”€ Lint + format: ruff, Google style โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
91[tool.ruff]
92target-version = "py313"
93# Google Python Style Guide: 80 columns.
94line-length = 80
95src = ["src", "tests"]
96 
97[tool.ruff.lint]
98select = [
99 "E", # pycodestyle errors
100 "W", # pycodestyle warnings
101 "F", # pyflakes
102 "I", # isort
103 "B", # flake8-bugbear
104 "C4", # flake8-comprehensions
105 "UP", # pyupgrade
106 "ARG", # flake8-unused-arguments
107 "SIM", # flake8-simplify
108 "TCH", # flake8-type-checking
109 "PTH", # flake8-use-pathlib
110 "RUF", # ruff-specific
111 "D", # pydocstyle (Google convention)
113ignore = [
114 "D100", # Missing docstring in public module
115 "D104", # Missing docstring in public package
116 "D105", # Missing docstring in magic method (dunders are self-evident)
118 
119[tool.ruff.lint.pydocstyle]
120convention = "google"
121 
122[tool.ruff.lint.flake8-type-checking]
123# Pydantic evaluates field-type annotations at runtime to build validators, so
124# their imports must NOT be moved into a TYPE_CHECKING block. Tell ruff that
125# annotations on these base classes are runtime-evaluated.
126runtime-evaluated-base-classes = [
127 "pydantic.BaseModel",
128 "froot.domain.base.Frozen",
130# Temporal resolves signal/query/run/activity type hints at runtime to (de)
131# serialize payloads, so those imports must NOT move into a TYPE_CHECKING block.
132runtime-evaluated-decorators = [
133 "temporalio.workflow.signal",
134 "temporalio.workflow.query",
135 "temporalio.workflow.run",
136 "temporalio.activity.defn",
โ‹ฏ 21 lines hidden (lines 138โ€“158)
138 
139[tool.ruff.lint.isort]
140known-first-party = ["froot"]
141 
142[tool.ruff.lint.per-file-ignores]
143# Tests document behavior through their names; full docstring coverage is noise.
144"tests/**" = [
145 "D100", "D101", "D102", "D103", "D104", "D107",
146 "ARG", # fakes mirror port signatures; unused params are the contract
147 "TC001", "TC002", "TC003", # tests don't need TYPE_CHECKING import gating
149# Activity stubs are unimplemented ports: their signatures are the contract, so
150# the params are intentionally unused until the real implementations land.
151"src/froot/workflow/activities.py" = ["ARG001"]
152 
153# โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
154[tool.pytest.ini_options]
155testpaths = ["tests"]
156asyncio_mode = "auto"
157asyncio_default_fixture_loop_scope = "function"
158addopts = "-v --cov=froot --cov-report=term-missing"

The image is unusual for a Python service: it carries git and npm. That is the loop's needs showing through, and the blast-radius story from ยง5 reappears as a property of the image.

Dockerfile ยท 32 lines
Dockerfile32 lines ยท Docker
โ‹ฏ 13 lines hidden (lines 1โ€“13)
1# The Temporal worker image. Built and pushed to ghcr.io by CI; deployed to the
2# DOKS cluster. The GitHub token and the model/OTEL endpoints are read from the
3# environment at runtime (never baked in); the worker connects to Temporal.
4#
5# Unlike a pure-Python worker, froot's image also carries `git` and `npm`: the
6# loop shallow-clones the target repo and regenerates its lockfile with
7# `npm install --package-lock-only --ignore-scripts` โ€” no node_modules and no
8# install scripts, so no project or dependency code ever runs here. The real
9# install + tests run in the target repo's own CI (the verification oracle).
10FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
11 
12# git: clone/branch/push the bump PR. nodejs+npm: lockfile-only regen + version
13# lookups (npm view). Slim โ€” no project/dependency code executes in this image.
14RUN apt-get update \
15 && apt-get install -y --no-install-recommends \
16 git nodejs npm ca-certificates \
17 && rm -rf /var/lib/apt/lists/*
18 
19WORKDIR /app
20ENV UV_COMPILE_BYTECODE=1 \
21 UV_LINK_MODE=copy \
22 UV_PYTHON_DOWNLOADS=never
23 
24# Runtime extras only (the model judge, GitHub/HTTP, OTEL) โ€” not the dev tooling.
25COPY pyproject.toml uv.lock README.md ./
26COPY src ./src
27RUN uv sync --frozen --no-editable \
28 --extra ai --extra github --extra otel
โ‹ฏ 4 lines hidden (lines 29โ€“32)
29 
30# Default entrypoint: the worker. The scan starter runs as a one-shot via a
31# `command:` override (uv run --no-sync python -m froot.scan_starter).
32ENTRYPOINT ["uv", "run", "--no-sync", "python", "-m", "froot.worker"]

The deployment is one worker plus two one-shot Jobs. The worker connects out to four things: Temporal, GitHub, the model proxy, and the telemetry collector. It runs no database and accepts no inbound traffic of any kind.

๐Ÿณ zo-k8s (DOKS ยท single node)

namespace: temporal

namespace: froot

long-poll tasks

start ScanWorkflow

https (token)

OpenAI /v1 (only when changelog exists)

tailscale serve

OTLP/HTTP traces + SDK metrics

๐Ÿ“ฆ froot-worker
(1 replica ยท git + npm)

โฏ๏ธ start-scan Job
(scan_starter)

โš™๏ธ temporal-frontend:7233

๐Ÿ“ก temporal-otel-collector

๐Ÿ”€ ollama.llm proxy
(nginx โ†’ tailnet)

๐Ÿ’ป Mac Studio
Ollama ยท Gemma 4 e4b

๐Ÿ™ GitHub
clone ยท PR ยท CI ยท labels

๐Ÿ“ˆ ClickStack / HyperDX

The worker connects out: long-polling Temporal, cloning and PRing GitHub, calling the Ollama proxy over the tailnet, and shipping telemetry to ClickStack. No inbound traffic.
infra/k8s/froot/manifests/10-worker.yaml ยท 67 lines
infra/k8s/froot/manifests/10-worker.yaml67 lines ยท YAML
โ‹ฏ 59 lines hidden (lines 1โ€“59)
1# The froot Temporal worker (both workflows + every activity). Long-running, no
2# inbound traffic โ€” it connects OUT to the Temporal frontend, to GitHub, and to
3# the Ollama egress proxy. One replica: a single worker is plenty for the volume.
4#
5# In-cluster wiring is fixed here; the GitHub token comes from the `froot-secrets`
6# Secret that install.sh creates from the gitignored infra/.env. The worker does
7# NOT need FROOT_REPOS (only the scan starter does) โ€” it just runs activities for
8# whatever bump/scan workflows are dispatched.
9apiVersion: apps/v1
10kind: Deployment
11metadata:
12 name: froot-worker
13 namespace: froot
14 labels:
15 app.kubernetes.io/name: froot
16 app.kubernetes.io/component: worker
17 app.kubernetes.io/part-of: froot
18spec:
19 replicas: 1
20 # The worker holds long-poll connections to Temporal; recreate, don't surge
21 # (the request-tight node can't fit a RollingUpdate surge pod anyway).
22 strategy:
23 type: Recreate
24 selector:
25 matchLabels:
26 app.kubernetes.io/name: froot
27 app.kubernetes.io/component: worker
28 template:
29 metadata:
30 labels:
31 app.kubernetes.io/name: froot
32 app.kubernetes.io/component: worker
33 app.kubernetes.io/part-of: froot
34 spec:
35 containers:
36 - name: worker
37 image: ghcr.io/mseeks/froot:latest
38 imagePullPolicy: Always
39 env:
40 - name: TEMPORAL_HOST
41 value: temporal-frontend.temporal.svc.cluster.local:7233
42 - name: TEMPORAL_NAMESPACE
43 value: froot
44 - name: TEMPORAL_TASK_QUEUE
45 value: froot
46 # The changelog-judge model: Gemma 4 e4b on the Mac Studio, reached
47 # through the in-cluster nginx proxy that egresses over the tailnet
48 # (see ../../tailscale). Only invoked when a real changelog exists.
49 - name: FROOT_OLLAMA_URL
50 value: http://ollama.llm:11434/v1
51 - name: FROOT_OLLAMA_MODEL
52 value: gemma4:e4b
53 # Traces (OTLP/HTTP) + Temporal SDK metrics -> temporal-otel-collector
54 # -> ClickStack. Structured outcome logs go to stdout (free filelog).
55 - name: FROOT_OTEL
56 value: "1"
57 envFrom:
58 - secretRef:
59 name: froot-secrets # FROOT_GITHUB_TOKEN
60 resources:
61 # The model runs externally (the Ollama tunnel); the worker only
62 # brokers Temporal + a shallow git clone + npm lockfile-only + HTTP.
63 # The shared node's request budget is ~98% reserved (but ~50% actual
64 # use), so the *request* is pinned tiny to schedule at all โ€” the
65 # limit gives real burst room for the npm/git/model spikes.
66 requests: { cpu: 50m, memory: 64Mi }
67 limits: { cpu: 500m, memory: 512Mi }
The verdict11๐ŸŽฌ Evidence table

Does the code keep its promises?

froot's spec lists principles that govern every decision. Below, each one is scored against the actual code, with the file that proves it. The point is simple. You should not have to take the README's word for any of it.

PrincipleUpheld byWhere
Loops must close โ€” all six ingredients presentdurable schedule, bounded PR, CI wait, PR revert, the outcome log and labels, human-approves-every-PRscan_workflow, bump_workflow, record_outcome
Spine-heavy, model-thinone model call per bump, framing not gating; ~90% of the loop is deterministicmodel_judge.py
CI is the oracle โ€” never re-run a repo's testsfroot reads CI status; the worker carries no test toolchaingithub.ci_status, Dockerfile
Derive, never store โ€” no database of its ownno seen-set; work re-derived each tick; outcome lives in GitHub and ClickStackscan_workflow, record_outcome
Chassis generalizes, loop specializesthe durable machinery imports no concrete adapter; signal plus lockfile-command plus prompt are the only loop-specific partsports, activities
Earn autonomy; record first, gate laterevery PR is human-approved; the track record is recorded but not yet acted oncompose.PR_LABELS, record_outcome
Six principles, six pieces of code-level evidence.
The verdict12๐ŸŽฌ Montage + checklist

Two galleries, and a verdict

Two patterns recur often enough to collect. First, the ways froot makes a wrong state impossible. Second, the disciplines that keep the workflows replay-safe. A reviewer can re-skim the sections above and confirm each row.

Make illegal states unrepresentableForbidsSeen in
frozen, closed base modelmutation; unknown or typo'd fieldsdomain/base.py
construction-time validatora candidate that is not a clean patchcandidate.py ยง2
discriminated unionsan untagged variant; bad deserializationchangelog, ci, state, events
subset type (TerminalCIStatus)recording an outcome against a pending CIci.py ยง2
TypeIs narrowingtreating a pending status as terminalci.py ยง2
assert_never on matchforgetting a case when a union growsecosystem, state_machine, model_judge
rejected, not raised, transitionan illegal event crashing the loopstate_machine.py ยง3
anchored field regexa slug or branch with a smuggled slashrepo.py, pull_request.py
SecretStra token leaking into logs or tracebackssettings.py ยง8
Nine ways froot makes a wrong state fail to compile, fail to construct, or fail safely.
Replay-safety disciplineMechanism
All I/O lives in activities, never workflowsthe six @activity.defn functions
No wall clock or asyncio.sleep in a workflowworkflow.now() and workflow.sleep() (ยง7)
Adapter stacks never enter the sandboxlazy import inside activity bodies
Bounded workflow historycontinue_as_new per scan tick (ยง7)
Permanent vs transient faults distinguishednon-retryable ApplicationError for misconfig and auth
Five disciplines, each checkable by eye, and exercised end-to-end by the time-skipping tests.

proves the template

loops read each other

decided on terrain that works

1 ยท Close one loop
dependency-patch, end-to-end
(you are here)

2 ยท Replicate
security-patch
(same chassis, sharper signal)

3 ยท Coordinate
notifier loops guard
a running durable app

4 ยท Fixers
flaky-test / refactor
(needs an agentic harness)

The staged path: close one loop, replicate it to prove it is a template, let loops coordinate, and only then take on fixers that write arbitrary code.

This was the reviewer's quarter. The full edition walks every one of the 2,986 source lines, if you want to read the rest.