What changed, and why

froot runs durable maintenance loops on Temporal. The worker image already shipped four workflows: the dependency-patch scan loop and its per-bump driver, plus a determinism-review loop and its per-PR driver. But only the scan loop had a committed go-live Job. The review loop was built, registered, and tested in the image — then never started. Nothing in this k8s stack ran python -m froot.review_starter, so the workflow that took real effort to build sat dormant after every install.

This PR closes that gap. It adds manage/start-review.yaml — the one-shot Job that kicks the review loop, a near-exact sibling of start-scan.yaml — and threads it through install.sh and the README so go-live now starts both loops. The rest is doc accuracy: the worker comments, the README, and the top-level CLAUDE.md infra inventory had all drifted (single npm loop, an omitted stack, an unverifiable log claim). No worker code changes — this is deployment wiring plus making the docs match what the image actually does.

The star: the review go-live Job

This is the only new file. It is a one-shot Job that runs the worker image's python -m froot.review_starter, which submits one long-lived ReviewWorkflow per repo in FROOT_REPOS. The header comment is the contract: each tick re-derives the repo's open PRs, leaves an advisory determinism comment where a Temporal workflow risks a replay hazard, then continues-as-new with no cursor. It self-skips when FROOT_REVIEW_ENABLED is off (it defaults on, so a plain apply goes live).

The whole file — short by design. Read the header contract, then the env + resources.

infra/k8s/froot/manage/start-review.yaml · 54 lines
infra/k8s/froot/manage/start-review.yaml54 lines · YAML
1# Start (once) the durable determinism-review loop for each repo in FROOT_REPOS.
2# A one-shot Job that runs the worker image's `python -m froot.review_starter`,
3# which submits a long-lived ReviewWorkflow per repo (id `froot-review-<owner>-
4# <name>`, ALLOW_DUPLICATE_FAILED_ONLY so a running loop is left untouched but a
5# terminated one can restart). Each tick re-derives the repo's open PRs, leaves
6# an advisory determinism comment where a workflow risks a replay hazard, and
7# continues-as-new (no cursor). Skips entirely when FROOT_REVIEW_ENABLED is off
8# (it defaults on).
9#
10# The sibling of manage/start-scan.yaml — apply AFTER the worker is rolled to an
11# image that contains review_starter:
12# kubectl apply -f manage/start-review.yaml
13# kubectl wait -n froot --for=condition=complete job/froot-start-review --timeout=60s
14# Re-applying is safe; delete the Job (or let its TTL expire) before re-applying.
15apiVersion: batch/v1
16kind: Job
17metadata:
18 name: froot-start-review
19 namespace: froot
20 labels:
21 app.kubernetes.io/name: froot
22 app.kubernetes.io/component: review-starter
23 app.kubernetes.io/part-of: froot
24spec:
25 backoffLimit: 3
26 ttlSecondsAfterFinished: 300
27 template:
28 metadata:
29 labels:
30 app.kubernetes.io/name: froot
31 app.kubernetes.io/component: review-starter
32 spec:
33 restartPolicy: Never
34 containers:
35 - name: review-starter
36 image: ghcr.io/mseeks/froot:latest
37 imagePullPolicy: Always
38 command: ["uv", "run", "--no-sync", "python", "-m", "froot.review_starter"]
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 envFrom:
47 - configMapRef:
48 name: froot-config # FROOT_REPOS (the review loop reads the same list)
49 resources:
50 # A trivial one-shot (connect to Temporal, start the workflow,
51 # exit); the request is pinned tiny so it schedules even where
52 # node request budgets are tight, alongside the worker's reservation.
53 requests: { cpu: 25m, memory: 32Mi }
54 limits: { cpu: 200m, memory: 192Mi }

Sibling check: how it differs from start-scan

The claim that this is "the sibling of start-scan.yaml" should be verifiable, not taken on faith. Place the two side by side. The Job spec is identical — backoffLimit: 3, ttlSecondsAfterFinished: 300, restartPolicy: Never, the same tiny 25m/32Mi request, the same Temporal env triple, the same froot-config mount. Four things differ, and only four: the Job/container name, the component label, the module the command runs, and the header prose. That is exactly the surface you want a reviewer to confirm.

The existing scan starter, for direct comparison — same shape, scan_starter instead of review_starter.

infra/k8s/froot/manage/start-scan.yaml · 51 lines
infra/k8s/froot/manage/start-scan.yaml51 lines · YAML
⋯ 31 lines hidden (lines 1–31)
1# Start (once) the durable scan loop for each repo in FROOT_REPOS — the go-live
2# trigger. A one-shot Job that runs the worker image's
3# `python -m froot.scan_starter`, which submits a long-lived ScanWorkflow per
4# repo (id `froot-scan-<owner>-<name>`, ALLOW_DUPLICATE_FAILED_ONLY so a running
5# loop is left untouched but a terminated one can restart). Each tick re-derives
6# the outstanding patches and continues-as-new (no cursor).
7#
8# Apply AFTER the worker is rolled to an image that contains scan_starter:
9# kubectl apply -f manage/start-scan.yaml
10# kubectl wait -n froot --for=condition=complete job/froot-start-scan --timeout=60s
11# Re-applying is safe; delete the Job (or let its TTL expire) before re-applying.
12apiVersion: batch/v1
13kind: Job
14metadata:
15 name: froot-start-scan
16 namespace: froot
17 labels:
18 app.kubernetes.io/name: froot
19 app.kubernetes.io/component: scan-starter
20 app.kubernetes.io/part-of: froot
21spec:
22 backoffLimit: 3
23 ttlSecondsAfterFinished: 300
24 template:
25 metadata:
26 labels:
27 app.kubernetes.io/name: froot
28 app.kubernetes.io/component: scan-starter
29 spec:
30 restartPolicy: Never
31 containers:
32 - name: scan-starter
33 image: ghcr.io/mseeks/froot:latest
34 imagePullPolicy: Always
35 command: ["uv", "run", "--no-sync", "python", "-m", "froot.scan_starter"]
36 env:
37 - name: TEMPORAL_HOST
38 value: temporal-frontend.temporal.svc.cluster.local:7233
39 - name: TEMPORAL_NAMESPACE
40 value: froot
41 - name: TEMPORAL_TASK_QUEUE
42 value: froot
43 envFrom:
44 - configMapRef:
45 name: froot-config # FROOT_REPOS / FROOT_SCAN_INTERVAL_SECONDS
46 resources:
47 # A trivial one-shot (connect to Temporal, start the workflow,
48 # exit); the request is pinned tiny so it schedules even where
49 # node request budgets are tight, alongside the worker's reservation.
50 requests: { cpu: 25m, memory: 32Mi }
51 limits: { cpu: 200m, memory: 192Mi }
Fieldstart-scan.yamlstart-review.yaml
Job / container namefroot-start-scan / scan-starterfroot-start-review / review-starter
component labelscan-starterreview-starter
command modulefroot.scan_starterfroot.review_starter
workflow id (from header)froot-scan-<owner>-<name>froot-review-<owner>-<name>
resources25m/32Mi req, 200m/192Mi limidentical
Everything load-bearing is shared; the delta is name, label, module, and prose.

Threading both loops through install + README

With the Job in place, go-live has to start it. install.sh previously printed a single "kick the scan loop" hint; now it points at both starters and the worker reads FROOT_REPOS too. The ConfigMap comment is corrected: it is no longer "the scan starter reads these" but "the scan + review starter Jobs read these, and the worker reads FROOT_REPOS too" — because the dashboard needs the repo list to frame its read-model.

The ConfigMap-comment fix, and the closing hint now kicking both loops.

infra/k8s/froot/install.sh · 63 lines
infra/k8s/froot/install.sh63 lines · Bash
⋯ 38 lines hidden (lines 1–38)
1#!/usr/bin/env bash
2# Install/update the froot worker on zo-k8s (namespace: froot). Idempotent —
3# re-run any time.
4#
5# Prerequisites:
6# - the Temporal stack is installed (../temporal/install.sh) — this connects to
7# temporal-frontend.temporal.svc.cluster.local:7233
8# - the worker image is published: ghcr.io/mseeks/froot:latest (CI builds it on
9# push to main). The ghcr package must be PUBLIC so the cluster can pull it
10# (no imagePullSecret is configured here) — flip it in the package settings
11# once, or add a read:packages pull secret.
12# - the token + config are in the gitignored infra/.env (see
13# secrets.example.env), then: set -a; source <repo>/infra/.env; set +a
15# Nothing secret or deployment-specific is written to disk: the token and the
16# repo list come from the environment into a Secret + ConfigMap.
17set -euo pipefail
18 
19NS=froot
20HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21# Preflight: refuse to run unless kubectl targets the zo-k8s cluster.
22source "$HERE/../lib/preflight.sh"
23require_context
24 
25# Required from infra/.env (the GitHub forge reads the token; the scan starter
26# reads the repo list). The Temporal + model wiring is fixed in the manifests.
27: "${FROOT_GITHUB_TOKEN:?source infra/.env first (see secrets.example.env)}"
28: "${FROOT_REPOS:?source infra/.env first (see secrets.example.env)}"
29# Optional: seconds between scan ticks once warm (default 86400 = daily).
30FROOT_SCAN_INTERVAL_SECONDS="${FROOT_SCAN_INTERVAL_SECONDS:-86400}"
31 
32kubectl get ns "$NS" >/dev/null 2>&1 || kubectl create namespace "$NS"
33 
34# Token -> Secret.
35kubectl create secret generic froot-secrets -n "$NS" \
36 --from-literal=FROOT_GITHUB_TOKEN="$FROOT_GITHUB_TOKEN" \
37 --dry-run=client -o yaml | kubectl apply -f -
38 
39# Non-secret config -> ConfigMap (which repos, scan cadence). The scan + review
40# starter Jobs read these, and the worker reads FROOT_REPOS too (the dashboard
41# needs them to frame the read-model).
42kubectl create configmap froot-config -n "$NS" \
43 --from-literal=FROOT_REPOS="$FROOT_REPOS" \
44 --from-literal=FROOT_SCAN_INTERVAL_SECONDS="$FROOT_SCAN_INTERVAL_SECONDS" \
45 --dry-run=client -o yaml | kubectl apply -f -
⋯ 11 lines hidden (lines 46–56)
46 
47# The Temporal namespace (idempotent Job), then the worker.
48kubectl apply -f "$HERE/manage/namespace-create.yaml"
49kubectl wait -n temporal --for=condition=complete \
50 job/froot-ns-create --timeout=60s || true
51kubectl apply -f "$HERE/manifests/"
52 
53# Pick up rotated secrets/config and roll out.
54kubectl rollout restart deploy/froot-worker -n "$NS" >/dev/null
55kubectl rollout status deploy/froot-worker -n "$NS" --timeout=180s
56 
57echo
58echo "froot worker up. Kick the loops (one ScanWorkflow + one ReviewWorkflow per"
59echo "repo in FROOT_REPOS; re-applying is a no-op for an already-running loop):"
60echo " kubectl apply -f $HERE/manage/start-scan.yaml"
61echo " kubectl wait -n froot --for=condition=complete job/froot-start-scan --timeout=60s"
62echo " kubectl apply -f $HERE/manage/start-review.yaml"
63echo " kubectl wait -n froot --for=condition=complete job/froot-start-review --timeout=60s"

The README's install and operate sections move in lockstep: step 3 now applies and waits on both Jobs, the layout table lists start-review.yaml, and a new operate paragraph documents the review loop's own go-live Job, default-on toggle, and where its ReviewWorkflows surface in the Temporal UI. The opening line also broadens the scope from "dependency-patch loops" to "code-maintenance loops" spanning npm and Python (uv), with the determinism reviewer as the second, advisory loop.

The install block — both Jobs applied and waited on at go-live.

infra/k8s/froot/README.md · 116 lines
infra/k8s/froot/README.md116 lines · Markdown
⋯ 43 lines hidden (lines 1–43)
1# froot on zo-k8s
2 
3Deploys [froot](https://github.com/mseeks/froot) — durable code-maintenance
4loops on Temporal — to the `zo-k8s` cluster. froot watches npm and Python (uv)
5repos on GitHub, and for each safe patch upgrade it opens a PR, waits on the
6repo's CI as the oracle, and records the outcome. It never merges. A second,
7advisory loop — the **determinism reviewer** — comments on PRs whose Temporal
8workflows risk a replay-nondeterminism hazard.
9 
10This stack is just the **worker** (all four workflows + every activity) plus the
11one-shot Jobs that create the Temporal namespace and start each loop — the
12dependency-patch scan loop and the determinism-review loop. The Temporal cluster
13itself lives in [`../temporal`](../temporal); the Ollama egress used by the judge
14model lives in [`../tailscale`](../tailscale).
15 
16## Layout
17 
18```
19froot/
20 install.sh idempotent install/update (run after sourcing infra/.env)
21 secrets.example.env what infra/.env must carry (token + repo list)
22 manifests/
23 00-namespace.yaml the `froot` k8s namespace
24 10-worker.yaml the worker Deployment (1 replica, small requests)
25 manage/
26 namespace-create.yaml Job: create the `froot` Temporal namespace (idempotent)
27 start-scan.yaml Job: start the dependency-patch scan loop(s) — go-live trigger
28 start-review.yaml Job: start the determinism-review loop(s) — go-live trigger
29```
30 
31## Wiring (fixed in the manifests)
32 
33| | |
34|---|---|
35| Temporal | `temporal-frontend.temporal.svc.cluster.local:7233`, namespace + task queue `froot` |
36| Model | `http://ollama.llm:11434/v1`, `gemma4:e4b` (in-cluster proxy → tailnet → Mac Studio) |
37| Telemetry | `FROOT_OTEL=1` — traces (OTLP/HTTP) + SDK metrics → `temporal-otel-collector` → ClickStack. Each outcome also emits a structured `loop_outcome` log line at INFO (surfaced on stdout once INFO logging is configured in the worker). |
38 
39Per-deployment config (the token + which repos) comes from the gitignored
40`infra/.env` → a Secret + ConfigMap; nothing private is committed.
41 
42## Install
43 
44```bash
45# 1. Put the token + repos in infra/.env (see secrets.example.env):
46# FROOT_GITHUB_TOKEN=... # contents:write + pull_requests:write
47# FROOT_REPOS=owner/name,... # npm by default; @uv for Python (e.g. acme/pylib@uv)
48set -a; source ../../.env; set +a
49 
50# 2. Roll out the worker (idempotent):
51./install.sh
52 
53# 3. Kick the loops (one ScanWorkflow + one ReviewWorkflow per repo; re-apply is
54# a no-op for an already-running loop):
55kubectl apply -f manage/start-scan.yaml
56kubectl wait -n froot --for=condition=complete job/froot-start-scan --timeout=60s
57kubectl apply -f manage/start-review.yaml
58kubectl wait -n froot --for=condition=complete job/froot-start-review --timeout=60s
59```
⋯ 57 lines hidden (lines 60–116)
60 
61## Image visibility
62 
63The worker pulls `ghcr.io/mseeks/froot:latest`, built by froot's CI on push to
64`main`. **No imagePullSecret is configured**, so the ghcr package must be
65**public** (flip it once in the package settings), or add a `read:packages`
66pull secret to the `froot` namespace and reference it from `10-worker.yaml`.
67 
68## Operate
69 
70```bash
71kubectl -n froot get pods
72kubectl -n froot logs deploy/froot-worker -f # worker stdout (outcome lines emit at INFO)
73kubectl -n froot logs job/froot-start-scan # what the scan loop was pointed at
74kubectl -n froot logs job/froot-start-review # what the review loop was pointed at
75```
76 
77Workflows (the long-lived scan + review loops, plus the per-bump and per-PR
78review drivers) are visible in the Temporal UI under the `froot` namespace;
79traces + logs are in HyperDX filtered to the `froot` namespace.
80 
81The **determinism-review** loop reuses the same `FROOT_REPOS`, is enabled by
82default (`FROOT_REVIEW_ENABLED`), and has its own go-live Job
83(`manage/start-review.yaml`) — the sibling of `start-scan.yaml`. Re-applying is a
84no-op for an already-running loop; its per-repo `ReviewWorkflow`s show up in the
85Temporal UI alongside the scan loops.
86 
87To repoint at different repos: edit `FROOT_REPOS` in `infra/.env`, re-run
88`install.sh` (refreshes the ConfigMap), then re-apply `manage/start-scan.yaml`
89(delete the finished Job first, or let its TTL expire).
90 
91## Read-model dashboard
92 
93The worker also serves a read-only **dashboard** on `:8080` — froot's reputation
94read-model, a 10,000ft view derived live from GitHub (the outcome ledger),
95Temporal (the live run ledger), and ClickStack/ClickHouse (best-effort run
96telemetry). It **stores nothing**: every request recomputes from those sources.
97There is no Ingress; reach it over a port-forward:
98 
99```bash
100kubectl -n froot port-forward svc/froot-dashboard 8080:8080
101# then open http://localhost:8080
102```
103 
104Wiring (fixed in `10-worker.yaml`): the worker reads `FROOT_REPOS` +
105`FROOT_SCAN_INTERVAL_SECONDS` from the `froot-config` ConfigMap and the GitHub
106token from `froot-secrets` (both already present), and `FROOT_CLICKHOUSE_URL` /
107`FROOT_CLICKHOUSE_USER` for telemetry. Toggle it with `FROOT_DASHBOARD_ENABLED`
108(default on) and `FROOT_DASHBOARD_PORT` (default `8080`).
109 
110**ClickHouse note:** the telemetry panel connects as ClickHouse's password-less
111`default` user. If a network policy or user ACL blocks the cross-namespace
112connection in-cluster, the panel simply renders *“telemetry unavailable”* and
113the rest of the dashboard (GitHub + Temporal) is unaffected. If you want that
114panel populated and `default` is restricted, point `FROOT_CLICKHOUSE_USER` at a
115read-capable user and add its password as `FROOT_CLICKHOUSE_PASSWORD` (from a
116Secret).

Doc accuracy: node-agnostic resources + an honest log claim

Two worker-manifest comments were quietly wrong, and this PR makes them honest.

First, the resource rationale used to reason about a two-node cluster ("the original node's request budget is ~98% reserved... while the second node has fresh headroom"). The cluster is single-node now, so that framing is stale. The new comment is node-agnostic: the request budget is tight, and with no metrics-server reserved != used, so the request is pinned tiny to schedule and the limit gives burst room. Same 50m/64Mi request — only the reasoning changed.

The resource comment, rewritten node-agnostic; the numbers are unchanged.

infra/k8s/froot/manifests/10-worker.yaml · 116 lines
infra/k8s/froot/manifests/10-worker.yaml116 lines · YAML
⋯ 85 lines hidden (lines 1–85)
1# The froot Temporal worker (all four workflows + every activity). Long-running. It
2# connects OUT to the Temporal frontend, GitHub, and the Ollama egress proxy,
3# and now also serves a read-only read-model dashboard on :8080 (no Ingress;
4# reach it with `kubectl -n froot port-forward svc/froot-dashboard 8080:8080`).
5# One replica: a single worker is plenty for the volume.
6#
7# In-cluster wiring is fixed here; the GitHub token comes from the `froot-secrets`
8# Secret that install.sh creates from the gitignored infra/.env. The worker now
9# also reads FROOT_REPOS + the scan cadence from the `froot-config` ConfigMap
10# (the dashboard needs them to frame the read-model), and the ClickHouse endpoint
11# for best-effort run telemetry.
12apiVersion: apps/v1
13kind: Deployment
14metadata:
15 name: froot-worker
16 namespace: froot
17 labels:
18 app.kubernetes.io/name: froot
19 app.kubernetes.io/component: worker
20 app.kubernetes.io/part-of: froot
21spec:
22 replicas: 1
23 # The worker holds long-poll connections to Temporal; recreate, don't surge
24 # (the request-tight node can't fit a RollingUpdate surge pod anyway).
25 strategy:
26 type: Recreate
27 selector:
28 matchLabels:
29 app.kubernetes.io/name: froot
30 app.kubernetes.io/component: worker
31 template:
32 metadata:
33 labels:
34 app.kubernetes.io/name: froot
35 app.kubernetes.io/component: worker
36 app.kubernetes.io/part-of: froot
37 spec:
38 # Don't inject the legacy Docker-link Service env vars: the froot-dashboard
39 # Service would otherwise set FROOT_DASHBOARD_PORT=tcp://<clusterIP>:8080,
40 # which collides with the dashboard's own FROOT_DASHBOARD_PORT setting and
41 # crashes settings parsing. froot addresses everything by DNS, so it needs
42 # none of these.
43 enableServiceLinks: false
44 containers:
45 - name: worker
46 image: ghcr.io/mseeks/froot:latest
47 imagePullPolicy: Always
48 ports:
49 # The read-model dashboard (read-only; port-forward to reach it).
50 - name: dashboard
51 containerPort: 8080
52 env:
53 - name: TEMPORAL_HOST
54 value: temporal-frontend.temporal.svc.cluster.local:7233
55 - name: TEMPORAL_NAMESPACE
56 value: froot
57 - name: TEMPORAL_TASK_QUEUE
58 value: froot
59 # Best-effort run-telemetry for the dashboard: ClickStack's
60 # ClickHouse over HTTP as the password-less `default` user. The panel
61 # degrades to "unavailable" if this is unreachable (e.g. a network
62 # policy or a user ACL blocks cross-namespace), so it is safe to set.
63 - name: FROOT_CLICKHOUSE_URL
64 value: http://clickstack-clickhouse-clickhouse-headless.observability.svc.cluster.local:8123
65 - name: FROOT_CLICKHOUSE_USER
66 value: default
67 # The judge model: Gemma 4 e4b on the Mac Studio, reached through the
68 # in-cluster nginx proxy that egresses over the tailnet (see
69 # ../../tailscale). Used by both judges — the changelog-risk judge and
70 # the determinism reviewer's frontier — and only when there is
71 # something to judge.
72 - name: FROOT_OLLAMA_URL
73 value: http://ollama.llm:11434/v1
74 - name: FROOT_OLLAMA_MODEL
75 value: gemma4:e4b
76 # Traces (OTLP/HTTP) + Temporal SDK metrics -> temporal-otel-collector
77 # -> ClickStack. Each outcome also emits a structured loop_outcome line
78 # at INFO (stdout filelog once INFO logging is configured in the worker).
79 - name: FROOT_OTEL
80 value: "1"
81 envFrom:
82 - secretRef:
83 name: froot-secrets # FROOT_GITHUB_TOKEN
84 - configMapRef:
85 name: froot-config # FROOT_REPOS + FROOT_SCAN_INTERVAL_SECONDS
86 resources:
87 # The model runs externally (the Ollama tunnel); the worker only
88 # brokers Temporal + a shallow git clone + npm/uv lockfile-only + HTTP.
89 # The cluster's request budget is tight (and with no metrics-server,
90 # reserved != used), so the *request* is pinned tiny to schedule
91 # cleanly; the limit gives real burst room for the npm/git/model spikes.
92 requests: { cpu: 50m, memory: 64Mi }
93 limits: { cpu: 500m, memory: 512Mi }
⋯ 23 lines hidden (lines 94–116)
94---
95# A ClusterIP for the read-model dashboard — no Ingress, no LoadBalancer. Reach
96# it from a laptop with:
97# kubectl -n froot port-forward svc/froot-dashboard 8080:8080
98# then open http://localhost:8080. The page is read-only and derives everything
99# on request (it stores nothing), so it is safe to leave running.
100apiVersion: v1
101kind: Service
102metadata:
103 name: froot-dashboard
104 namespace: froot
105 labels:
106 app.kubernetes.io/name: froot
107 app.kubernetes.io/component: dashboard
108 app.kubernetes.io/part-of: froot
109spec:
110 selector:
111 app.kubernetes.io/name: froot
112 app.kubernetes.io/component: worker
113 ports:
114 - name: dashboard
115 port: 8080
116 targetPort: dashboard

Second, the telemetry comment used to assert "structured outcome logs go to stdout (free filelog)." That over-claims: it states a delivery the worker does not yet guarantee. The corrected comment states only what is verifiable — each outcome emits a structured loop_outcome line at INFO — and is explicit that stdout delivery follows once INFO logging is configured in the worker. The README and the operate hint carry the same softened wording.

The judge-model comment now names both judges, and the telemetry claim is downgraded to what's verifiable.

infra/k8s/froot/manifests/10-worker.yaml · 116 lines
infra/k8s/froot/manifests/10-worker.yaml116 lines · YAML
⋯ 66 lines hidden (lines 1–66)
1# The froot Temporal worker (all four workflows + every activity). Long-running. It
2# connects OUT to the Temporal frontend, GitHub, and the Ollama egress proxy,
3# and now also serves a read-only read-model dashboard on :8080 (no Ingress;
4# reach it with `kubectl -n froot port-forward svc/froot-dashboard 8080:8080`).
5# One replica: a single worker is plenty for the volume.
6#
7# In-cluster wiring is fixed here; the GitHub token comes from the `froot-secrets`
8# Secret that install.sh creates from the gitignored infra/.env. The worker now
9# also reads FROOT_REPOS + the scan cadence from the `froot-config` ConfigMap
10# (the dashboard needs them to frame the read-model), and the ClickHouse endpoint
11# for best-effort run telemetry.
12apiVersion: apps/v1
13kind: Deployment
14metadata:
15 name: froot-worker
16 namespace: froot
17 labels:
18 app.kubernetes.io/name: froot
19 app.kubernetes.io/component: worker
20 app.kubernetes.io/part-of: froot
21spec:
22 replicas: 1
23 # The worker holds long-poll connections to Temporal; recreate, don't surge
24 # (the request-tight node can't fit a RollingUpdate surge pod anyway).
25 strategy:
26 type: Recreate
27 selector:
28 matchLabels:
29 app.kubernetes.io/name: froot
30 app.kubernetes.io/component: worker
31 template:
32 metadata:
33 labels:
34 app.kubernetes.io/name: froot
35 app.kubernetes.io/component: worker
36 app.kubernetes.io/part-of: froot
37 spec:
38 # Don't inject the legacy Docker-link Service env vars: the froot-dashboard
39 # Service would otherwise set FROOT_DASHBOARD_PORT=tcp://<clusterIP>:8080,
40 # which collides with the dashboard's own FROOT_DASHBOARD_PORT setting and
41 # crashes settings parsing. froot addresses everything by DNS, so it needs
42 # none of these.
43 enableServiceLinks: false
44 containers:
45 - name: worker
46 image: ghcr.io/mseeks/froot:latest
47 imagePullPolicy: Always
48 ports:
49 # The read-model dashboard (read-only; port-forward to reach it).
50 - name: dashboard
51 containerPort: 8080
52 env:
53 - name: TEMPORAL_HOST
54 value: temporal-frontend.temporal.svc.cluster.local:7233
55 - name: TEMPORAL_NAMESPACE
56 value: froot
57 - name: TEMPORAL_TASK_QUEUE
58 value: froot
59 # Best-effort run-telemetry for the dashboard: ClickStack's
60 # ClickHouse over HTTP as the password-less `default` user. The panel
61 # degrades to "unavailable" if this is unreachable (e.g. a network
62 # policy or a user ACL blocks cross-namespace), so it is safe to set.
63 - name: FROOT_CLICKHOUSE_URL
64 value: http://clickstack-clickhouse-clickhouse-headless.observability.svc.cluster.local:8123
65 - name: FROOT_CLICKHOUSE_USER
66 value: default
67 # The judge model: Gemma 4 e4b on the Mac Studio, reached through the
68 # in-cluster nginx proxy that egresses over the tailnet (see
69 # ../../tailscale). Used by both judges — the changelog-risk judge and
70 # the determinism reviewer's frontier — and only when there is
71 # something to judge.
72 - name: FROOT_OLLAMA_URL
73 value: http://ollama.llm:11434/v1
74 - name: FROOT_OLLAMA_MODEL
75 value: gemma4:e4b
76 # Traces (OTLP/HTTP) + Temporal SDK metrics -> temporal-otel-collector
77 # -> ClickStack. Each outcome also emits a structured loop_outcome line
78 # at INFO (stdout filelog once INFO logging is configured in the worker).
⋯ 38 lines hidden (lines 79–116)
79 - name: FROOT_OTEL
80 value: "1"
81 envFrom:
82 - secretRef:
83 name: froot-secrets # FROOT_GITHUB_TOKEN
84 - configMapRef:
85 name: froot-config # FROOT_REPOS + FROOT_SCAN_INTERVAL_SECONDS
86 resources:
87 # The model runs externally (the Ollama tunnel); the worker only
88 # brokers Temporal + a shallow git clone + npm/uv lockfile-only + HTTP.
89 # The cluster's request budget is tight (and with no metrics-server,
90 # reserved != used), so the *request* is pinned tiny to schedule
91 # cleanly; the limit gives real burst room for the npm/git/model spikes.
92 requests: { cpu: 50m, memory: 64Mi }
93 limits: { cpu: 500m, memory: 512Mi }
94---
95# A ClusterIP for the read-model dashboard — no Ingress, no LoadBalancer. Reach
96# it from a laptop with:
97# kubectl -n froot port-forward svc/froot-dashboard 8080:8080
98# then open http://localhost:8080. The page is read-only and derives everything
99# on request (it stores nothing), so it is safe to leave running.
100apiVersion: v1
101kind: Service
102metadata:
103 name: froot-dashboard
104 namespace: froot
105 labels:
106 app.kubernetes.io/name: froot
107 app.kubernetes.io/component: dashboard
108 app.kubernetes.io/part-of: froot
109spec:
110 selector:
111 app.kubernetes.io/name: froot
112 app.kubernetes.io/component: worker
113 ports:
114 - name: dashboard
115 port: 8080
116 targetPort: dashboard

Closing the inventory gap: CLAUDE.md

The top-level CLAUDE.md keeps a one-line-per-stack infra inventory: observability, temporal, tailscale. The froot stack was missing — an omission, since it is a deployed, install-script-managed workload like the others. This PR appends it: a froot bullet that names the worker, the four-workflow surface (dependency-patch for npm + uv plus the advisory determinism-review loop), the read-only dashboard, and that it points outward at GitHub repos and rides the Temporal cluster, Ollama egress, and ClickStack — deployed via its own install.sh, not Terraform.

The inventory line — the froot clause is the appended tail (the rest is unchanged context).

CLAUDE.md · 35 lines
CLAUDE.md35 lines · Markdown
⋯ 32 lines hidden (lines 1–32)
1# Zo
2 
3You are **Zo**, an AI assistant living within a personal workspace—a creative home for everything the user is interested in, working on, learning, and creating. This is where ideas live, projects grow, and experimentation happens.
4 
5## Identity
6 
7You are warm, thoughtful, and genuinely curious. You bring careful attention to every interaction, catching details others might miss and anticipating what comes next. You explain things clearly and thoroughly when depth is needed, yet you know when brevity serves better. You're a craftsperson who takes pride in getting things right.
8 
9## Guidelines
10 
11- When the user expresses a lasting preference ("from now on," "always," "in the future," etc.), update this file to reflect that preference.
12- Break complex tasks into manageable pieces.
13- Track progress proactively with todo lists for complex work.
14- Balance thoroughness with efficiency. Be precise and concise.
15- **Chat style:** Be concise. Use intelligent formatting (headers, short lists, tables, code/inline code) to break up large blocks of text and communicate efficiently. Use emojis in reasonable moderation as quick visual anchors and to valuable effect. Lead with the answer; cut preamble and filler.
16- Practice intellectual honesty and push back on questionable ideas while still being flexible enough to adapt to new ideas that can be adequately supported.
17- Run terminal commands in short combinations rather than clever mega combos to allow for easy review & understanding by the user and better matching for auto approval rules.
18- Always check if you're working in a git worktree.
19 - If you are, then know that submodules will need to be initialized before working with them.
20 - You are likely also in a tmux session named `<repo>_<branch>` — e.g. worktree dir `pathological` → branch `worktree-pathological` → session `zo_worktree-pathological`. Derive the session name with `<repo>_<branch>` (confirm via `tmux display-message -p '#S'`).
21 - When asked to **delete the worktree**, assume that also means **kill the accompanying tmux session** and to **remove the related task from `zo.code-workspace`**. Do the tmux kill **LAST**, since it terminates this very process (so finish all other cleanup first).
22 
23## Pull requests
24 
25- Whenever you open a PR, use the **`make-read-thru`** skill to generate a code-level reading guide *of that PR's content* — a walkthrough a reviewer can read top to bottom and vouch for (size it to the change; the skill's `references/depth-and-scope.md` has the tiers).
26- **Deploy** the guide to the static site: write it to `projects/static/public/read-thru/<repo>/pr-<n>-<slug>.html` (slug = short kebab summary of the PR), push the static repo's `main`, then run `infra/k8s/static/install.sh`.
27- In the PR body, add a **bare link, no preamble** to the page's clean URL (no `.html` suffix): `https://static.mseeks.me/read-thru/<repo>/pr-<n>-<slug>`.
28- Deploy/namespacing above is **personal-workspace-specific and lives only here** — the `make-read-thru` skill knows nothing about the static site or k8s; it just produces the HTML.
29 
30## Infrastructure
31 
32- `infra/` holds Terraform for personal DigitalOcean infrastructure (single source of truth for the DO account). Currently DNS plus a two-node DOKS cluster (`infra/kubernetes.tf``zo-k8s`, 2x `s-4vcpu-8gb` nodes in nyc3, ~$96/mo) running the ClickStack/HyperDX observability stack (deployed via Helm with committed values in `infra/k8s/observability/`, NOT Terraform-managed); App Platform static sites/apps later.
33- On-cluster workloads are deployed with **kubectl/Helm + install scripts, NOT Terraform** (Terraform owns only the cluster). **Headroom nuance (don't get fooled):** the observability+Temporal stack's stateful pods stay **pinned to node 1**, whose *request budget* there is nearly full — sum of pod **memory** requests ≈98% of that node's allocatable (CPU ~90%) per `kubectl describe node`, so **node 1 is memory-bound, not CPU-bound** (actual CPU usage is only ~50%). The **second node** is fresh (~13% mem / ~15% CPU requests), so cluster-wide requests are only ~56% memory / ~53% CPU — memory is no longer the *cluster* binding constraint. Requests are reservations, not usage; most pods (esp. ClickStack's mongodb/clickhouse, Temporal server) reserve far more than they use, and there's **no metrics-server** so `kubectl top` won't work in-cluster. Consequence: pin **small requests** when adding pods — a pod can still go `Pending` on a node despite real headroom. Reclaim room on a saturated node by right-sizing those over-provisioned requests rather than scaling the pool further. Stacks: `infra/k8s/observability/` (ClickStack), `infra/k8s/temporal/` (a lean Temporal cluster — `auto-setup` + Postgres + UI + a dedicated OTel collector, emitting logs/metrics/traces to ClickStack; namespaces & schedules managed via committed idempotent `temporal`-CLI k8s Jobs, deliberately not a Terraform provider), `infra/k8s/tailscale/` (Tailscale operator giving the cluster **egress** to Ollama on the Mac Studio — Ollama runs via a `com.zo.ollama` LaunchAgent on the Mac, exposed to the tailnet with `tailscale serve --tcp`, and fronted in-cluster by an nginx Host-rewrite proxy reachable at `http://ollama.llm:11434`; see its README for the macOS-firewall and Ollama DNS-rebind/Host-check nuances), `infra/k8s/froot/` (the **froot** worker — durable code-maintenance loops on Temporal: dependency-patch for npm + uv plus an advisory determinism-review loop, with a derived read-only read-model dashboard; points *outward* at GitHub repos, uses the Temporal cluster + the Ollama egress + ClickStack, and is deployed via its own `install.sh`, NOT Terraform).
⋯ 2 lines hidden (lines 34–35)
34- Secrets never go in the repo: the DO token is read from `DIGITALOCEAN_ACCESS_TOKEN` via gitignored `infra/.env` (used by both terraform and doctl).
35- Project-specific deployments live centrally in `infra/` and point *outward* at public repos; never put Terraform inside the (open-source) project repos.