froot, the essential reading
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.
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.
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.
The shape of the whole thing
src/froot/__init__.pyfroot 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.
| Layer | Modules | Lines | Its one job |
|---|---|---|---|
| ๐ง domain | version ยท candidate ยท ci ยท state ยท events ยท effects ยท outcome ยท โฆ | 727 | frozen value objects; illegal states cannot be built |
| ๐งฎ policy | candidates ยท naming ยท compose ยท state_machine | 363 | pure decisions: selection, idempotency keys, PR text, the loop's transitions |
| ๐ ports | protocols.py | 114 | four Protocols; the seam to the impure world |
| ๐ adapters | npm ยท github ยท model_judge ยท changelog_http ยท telemetry ยท โฆ | 845 | real integrations: npm, git, GitHub, the model, OTEL |
| โ๏ธ workflow | scan_workflow ยท bump_workflow ยท activities ยท โฆ | 521 | the durable Temporal spine: two workflows, six activities |
| ๐ entry / config | worker ยท scan_starter ยท settings | 284 | the runnable worker, the go-live trigger, all env config |
Illegal states you cannot build
src/froot/domain/version.pysrc/froot/domain/candidate.pysrc/froot/domain/ci.pyThe 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
โฏ 91 lines hidden (lines 1โ91)
โฏ 11 lines hidden (lines 113โ123)
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
โฏ 37 lines hidden (lines 1โ37)
โฏ 27 lines hidden (lines 47โ73)
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 is a strict subset of CIStatus, and the recorded outcome is typed to the subset.src/froot/domain/ci.py ยท 68 lines
โฏ 51 lines hidden (lines 1โ51)
The loop as a pure function
src/froot/policy/state_machine.pysrc/froot/domain/effects.pyThe 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.
src/froot/policy/state_machine.py ยท 171 lines
โฏ 93 lines hidden (lines 1โ93)
โฏ 30 lines hidden (lines 114โ143)
โฏ 12 lines hidden (lines 160โ171)
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.
| State | Expected event | Next state | Effect emitted |
|---|---|---|---|
Discovered | ChangelogJudged | Judged | OpenPullRequest |
Judged | PullRequestReady | AwaitingCi | AwaitCi |
AwaitingCi | CiResolved (terminal) | Recorded | RecordOutcome |
AwaitingCi | CiResolved (pending) | unchanged | rejected, no raise |
| any | any other event | unchanged | rejected, no raise |
src/froot/domain/effects.py ยท 57 lines
โฏ 23 lines hidden (lines 1โ23)
Four typed promises
src/froot/ports/protocols.pyThis 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.
src/froot/ports/protocols.py ยท 114 lines
โฏ 27 lines hidden (lines 1โ27)
โฏ 63 lines hidden (lines 52โ114)
| Port | Real adapter | Fake (tests) |
|---|---|---|
PackageManager โ read upgrades, regen the lockfile | NpmPackageManager | FakePackageManager |
Forge โ checkout, PR, CI status, labels | GitHubForge | FakeForge |
ChangelogSource โ fetch release notes | HttpChangelogSource | FakeChangelogSource |
ModelJudge โ judge a changelog | PydanticAiJudge | FakeJudge |
Where the I/O lives, and where the safety is
src/froot/adapters/npm.pysrc/froot/adapters/github.pyThe 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
โฏ 148 lines hidden (lines 1โ148)
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
โฏ 68 lines hidden (lines 1โ68)
โฏ 180 lines hidden (lines 97โ276)
| check runs | combined status | result |
|---|---|---|
| none | none | CIAbsent (nothing to verify) |
| any not completed | โ | CIPending (keep waiting) |
| any bad conclusion | โ | CIFailed (with failing names) |
| all completed and good | success / none | CIPassed |
The one model call
src/froot/adapters/model_judge.pyHere 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
โฏ 30 lines hidden (lines 1โ30)
โฏ 10 lines hidden (lines 44โ53)
โฏ 22 lines hidden (lines 67โ88)
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.
Driving the pure machine, durably
src/froot/workflow/bump_workflow.pysrc/froot/workflow/scan_workflow.pyThis 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
src/froot/workflow/bump_workflow.py ยท 138 lines
โฏ 62 lines hidden (lines 1โ62)
โฏ 34 lines hidden (lines 89โ122)
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
โฏ 30 lines hidden (lines 1โ30)
All the knobs, none of the secrets
src/froot/config/settings.pyEvery 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
โฏ 83 lines hidden (lines 1โ83)
โฏ 28 lines hidden (lines 98โ125)
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.
An hour-long wait, tested in milliseconds
tests/test_bump_workflow.pyBecause 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
โฏ 43 lines hidden (lines 1โ43)
โฏ 32 lines hidden (lines 63โ94)
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.
The gate, the image, and one worker pointed outward
pyproject.tomlDockerfileinfra/k8s/froot/manifests/10-worker.yamlThe 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
โฏ 55 lines hidden (lines 1โ55)
โฏ 46 lines hidden (lines 76โ121)
โฏ 21 lines hidden (lines 138โ158)
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
โฏ 13 lines hidden (lines 1โ13)
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.
infra/k8s/froot/manifests/10-worker.yaml ยท 67 lines
โฏ 59 lines hidden (lines 1โ59)
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.
| Principle | Upheld by | Where |
|---|---|---|
| Loops must close โ all six ingredients present | durable schedule, bounded PR, CI wait, PR revert, the outcome log and labels, human-approves-every-PR | scan_workflow, bump_workflow, record_outcome |
| Spine-heavy, model-thin | one model call per bump, framing not gating; ~90% of the loop is deterministic | model_judge.py |
| CI is the oracle โ never re-run a repo's tests | froot reads CI status; the worker carries no test toolchain | github.ci_status, Dockerfile |
| Derive, never store โ no database of its own | no seen-set; work re-derived each tick; outcome lives in GitHub and ClickStack | scan_workflow, record_outcome |
| Chassis generalizes, loop specializes | the durable machinery imports no concrete adapter; signal plus lockfile-command plus prompt are the only loop-specific parts | ports, activities |
| Earn autonomy; record first, gate later | every PR is human-approved; the track record is recorded but not yet acted on | compose.PR_LABELS, record_outcome |
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 unrepresentable | Forbids | Seen in |
|---|---|---|
| frozen, closed base model | mutation; unknown or typo'd fields | domain/base.py |
| construction-time validator | a candidate that is not a clean patch | candidate.py ยง2 |
| discriminated unions | an untagged variant; bad deserialization | changelog, ci, state, events |
subset type (TerminalCIStatus) | recording an outcome against a pending CI | ci.py ยง2 |
TypeIs narrowing | treating a pending status as terminal | ci.py ยง2 |
assert_never on match | forgetting a case when a union grows | ecosystem, state_machine, model_judge |
| rejected, not raised, transition | an illegal event crashing the loop | state_machine.py ยง3 |
| anchored field regex | a slug or branch with a smuggled slash | repo.py, pull_request.py |
SecretStr | a token leaking into logs or tracebacks | settings.py ยง8 |
| Replay-safety discipline | Mechanism |
|---|---|
| All I/O lives in activities, never workflows | the six @activity.defn functions |
No wall clock or asyncio.sleep in a workflow | workflow.now() and workflow.sleep() (ยง7) |
| Adapter stacks never enter the sandbox | lazy import inside activity bodies |
| Bounded workflow history | continue_as_new per scan tick (ยง7) |
| Permanent vs transient faults distinguished | non-retryable ApplicationError for misconfig and auth |
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.