carlos / platform Public

plan: host & process monitoring — 13 tasks, TDD, exact seams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4edd1e23e8d18a9a875cf71588c459dda19b5562 parent 396e932
1 file changed, +720 −0
  • docs/superpowers/plans/2026-08-02-host-process-monitoring.md +720 −0
diff --git a/docs/superpowers/plans/2026-08-02-host-process-monitoring.md b/docs/superpowers/plans/2026-08-02-host-process-monitoring.md
new file mode 100644
index 000000000..a68b35ddc
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-host-process-monitoring.md
@@ -0,0 +1,720 @@
+# Host & Process Monitoring Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Operator-grade CPU/memory/disk monitoring per host and per systemd unit, with bucket-stored history, on-box alerting (webhook + console-relayed keymail), an operator dashboard, and owner-facing per-app metrics in the console.
+
+**Architecture:** A new pure package `internal/metrics` (parsers, storage shapes, rollups, alert rules — no OS access) + OS-reading probes and a 60s sampler loop in `internal/host` (same seam as the existing statusTick) + console additions (alert watcher with keymail delivery, operator dashboard, owner app metrics). Storage is all-new bucket prefixes `metrics/` and `alerts/`; one additive method (`Delete`) lands on `release.Store`.
+
+**Tech Stack:** Go stdlib only (no new deps). `release.Store` for bucket IO, `html/template` console pages, cgroup v2 files for per-unit readings.
+
+## Global Constraints
+
+- Spec: `docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md` — read it first.
+- All probes/readers **fail toward nil, never a guessed zero** (convention documented in `internal/host/probes.go:1-10`).
+- Wire shapes are **additive**: nothing that exists in a bucket today changes shape; all new objects carry `"v": 1`.
+- The public status page writer (`internal/status` fleet publish) must never read `metrics/` or `alerts/` — guarded by a contract test (Task 8).
+- Console security gates ordered: session → CSRF → owner/operator (byte-identical `http.NotFound`) → `requireFreshAuth` LAST. Operator gate copies `handleFleet` (`internal/console/server.go:888-912`) exactly: every failure path answers plain `http.NotFound`.
+- Owner metrics boundary: an owner sees ONLY their own units' cgroup readings — never host totals, other tenants' units, or anything from `alerts/`.
+- Vendored assets only: icons are in-code SVG constants (`internal/console/icons.go`), sparklines are server-computed inline SVG. No CDN, no JS libraries.
+- Build gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, `gofmt -l` clean for touched files. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, disable sandbox if the toolchain reports "read-only file system".
+- Times in bucket keys are **UTC**: hour key `2006-01-02-15`, day key `2006-01-02`.
+- Commit messages follow repo style: lowercase prefix (`metrics:`, `host:`, `console:`, `release:`), what + why.
+
+---
+
+### Task 1: `release.Store` gains `Delete`
+
+**Files:**
+- Modify: `internal/release/store.go` (interface at :34-42, DirStore :52-157, S3Store :164-280)
+- Test: `internal/release/store_test.go` (follow existing DirStore test conventions in that file)
+
+**Interfaces:**
+- Consumes: existing `Store` interface `{Get(ctx, key) ([]byte, string, error); Put(ctx, key, body, ifMatch) (string, error); List(ctx, prefix) ([]string, error)}`.
+- Produces: `Delete(ctx context.Context, key string) error` on the interface and both impls. Deleting a missing key is **not an error** (idempotent — pruning re-runs must not fail). Any test doubles embedding `release.Store` (e.g. `flakyPutStore` in `internal/status/status_test.go:21-33`) inherit it via embedding — verify with the full-repo build.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestDirStoreDelete(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := s.Put(ctx, "metrics/boxes/b/raw/2026-08-01-10.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Delete(ctx, "metrics/boxes/b/raw/2026-08-01-10.json"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if _, _, err := s.Get(ctx, "metrics/boxes/b/raw/2026-08-01-10.json"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get after Delete = %v, want ErrNotExist", err)
+ }
+ // Idempotent: deleting a key that isn't there is fine.
+ if err := s.Delete(ctx, "metrics/boxes/b/raw/2026-08-01-10.json"); err != nil {
+ t.Fatalf("second Delete: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run `go test ./internal/release/ -run TestDirStoreDelete -count=1`** — expect FAIL (method undefined).
+
+- [ ] **Step 3: Implement.** Add `Delete(ctx context.Context, key string) error` to the `Store` interface with a doc comment stating idempotency. DirStore: under `d.mu`, `os.Remove(filepath.Join(d.root, filepath.FromSlash(key)))`, map `os.IsNotExist` to nil. S3Store: `s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: aws.String(s.Bucket), Key: aws.String(key)})` — S3 DeleteObject on a missing key already succeeds.
+
+- [ ] **Step 4: Run `go build ./... && go test ./internal/release/ -count=1`** — expect PASS, whole repo compiles (any non-embedding Store double in tests will surface here; give it a trivial Delete).
+
+- [ ] **Step 5: Commit** `release: Store.Delete — additive, idempotent; metrics pruning needs it`
+
+---
+
+### Task 2: `internal/metrics` — parsers (pure, no OS access)
+
+**Files:**
+- Create: `internal/metrics/parse.go`, `internal/metrics/parse_test.go`
+
+**Interfaces:**
+- Produces (consumed by Tasks 6-7):
+
+```go
+package metrics
+
+// CPUCounters is a monotonic reading of /proc/stat's aggregate cpu line.
+type CPUCounters struct{ Busy, Total uint64 }
+
+func ParseProcStat(text string) *CPUCounters // nil on any parse failure
+func CPUFrac(prev, cur *CPUCounters) *float64 // nil unless both non-nil and cur.Total > prev.Total
+func ParseLoadAvg(text string) (l1, l5, l15 *float64) // all-nil on failure
+func ParseCgroupCPUUsec(text string) *int64 // usage_usec from cpu.stat; nil on failure
+func ParseCgroupInt(text string) *int64 // memory.current / memory.peak; nil on failure ("max" -> nil)
+func ParseCgroupIO(text string) (rbytes, wbytes *int64) // io.stat rbytes/wbytes summed across devices; both nil on failure
+func UnitCPUFrac(prevUsec, curUsec *int64, elapsed time.Duration) *float64 // nil unless both non-nil, cur >= prev, elapsed > 0
+```
+
+- [ ] **Step 1: Write failing tests** covering, minimum:
+
+```go
+func TestParseProcStat(t *testing.T) {
+ // user nice system idle iowait irq softirq steal
+ c := ParseProcStat("cpu 100 20 30 800 50 5 5 0 0 0\ncpu0 1 2 3 4 5 6 7 8 9 10\n")
+ if c == nil {
+ t.Fatal("nil for valid input")
+ }
+ // Busy = everything except idle(800) and iowait(50) = 160; Total = 1010.
+ if c.Busy != 160 || c.Total != 1010 {
+ t.Fatalf("got busy=%d total=%d, want 160/1010", c.Busy, c.Total)
+ }
+ if ParseProcStat("") != nil || ParseProcStat("cpu x y z") != nil {
+ t.Fatal("garbage must parse to nil")
+ }
+}
+
+func TestCPUFrac(t *testing.T) {
+ prev := &CPUCounters{Busy: 100, Total: 1000}
+ cur := &CPUCounters{Busy: 160, Total: 1100}
+ f := CPUFrac(prev, cur)
+ if f == nil || math.Abs(*f-0.6) > 1e-9 { // 60 busy / 100 total
+ t.Fatalf("got %v, want 0.6", f)
+ }
+ if CPUFrac(nil, cur) != nil || CPUFrac(prev, nil) != nil {
+ t.Fatal("nil input must be nil")
+ }
+ if CPUFrac(cur, prev) != nil { // counters went backwards (reboot) — no guess
+ t.Fatal("backwards counters must be nil")
+ }
+}
+
+func TestParseCgroupCPUUsec(t *testing.T) {
+ v := ParseCgroupCPUUsec("usage_usec 4500000\nuser_usec 3000000\nsystem_usec 1500000\n")
+ if v == nil || *v != 4500000 {
+ t.Fatalf("got %v, want 4500000", v)
+ }
+ if ParseCgroupCPUUsec("") != nil {
+ t.Fatal("empty must be nil")
+ }
+}
+
+func TestParseCgroupIO(t *testing.T) {
+ r, w := ParseCgroupIO("259:0 rbytes=1000 wbytes=2000 rios=10 wios=20 dbytes=0 dios=0\n259:1 rbytes=500 wbytes=100 rios=1 wios=1 dbytes=0 dios=0\n")
+ if r == nil || w == nil || *r != 1500 || *w != 2100 {
+ t.Fatalf("got r=%v w=%v, want 1500/2100", r, w)
+ }
+}
+```
+
+Plus `TestParseLoadAvg` ("0.52 0.41 0.30 1/234 5678" → 0.52/0.41/0.30; garbage → all nil), `TestParseCgroupInt` ("123456\n" → 123456; "max" → nil), `TestUnitCPUFrac` (3_000_000 usec over 60s = 0.05; backwards/nil/zero-elapsed → nil).
+
+- [ ] **Step 2: Run `go test ./internal/metrics/ -count=1`** — FAIL (package doesn't exist).
+
+- [ ] **Step 3: Implement `parse.go`.** `/proc/stat` cpu line: fields after "cpu" are user nice system idle iowait irq softirq steal [guest guest_nice]; Total = sum of first 8, Busy = Total − idle − iowait (guest columns are already included in user/nice on modern kernels — sum only the first 8 to avoid double-count). Every parser returns nil on any missing/garbled field, per the package doc comment you write at the top: mirror the "fail toward nil" language of `internal/host/probes.go:1-10`.
+
+- [ ] **Step 4: Run tests** — PASS. **Step 5: Commit** `metrics: pure parsers for /proc and cgroup v2 readings`
+
+---
+
+### Task 3: `internal/metrics` — samples, chunk keys, chunk write, prune list
+
+**Files:**
+- Create: `internal/metrics/sample.go`, `internal/metrics/chunk.go`, `internal/metrics/chunk_test.go`
+
+**Interfaces (produces, consumed by Tasks 6-7 and 10-12):**
+
+```go
+type HostSample struct {
+ CPUFrac *float64 `json:"cpu"`
+ Load1 *float64 `json:"load1"`
+ Load5 *float64 `json:"load5"`
+ Load15 *float64 `json:"load15"`
+ MemFrac *float64 `json:"mem"`
+ DiskFrac *float64 `json:"disk"`
+}
+type UnitSample struct {
+ CPUFrac *float64 `json:"cpu"`
+ MemBytes *int64 `json:"mem_bytes"`
+ MemPeakBytes *int64 `json:"mem_peak_bytes"`
+ ReadBytes *int64 `json:"read_bytes"` // cumulative
+ WriteBytes *int64 `json:"write_bytes"` // cumulative
+}
+type Sample struct {
+ At time.Time `json:"at"`
+ Host HostSample `json:"host"`
+ Units map[string]UnitSample `json:"units"` // key: full unit name, e.g. carlos-app@x.example.com.service
+}
+type RawChunk struct {
+ V int `json:"v"` // 1
+ Box string `json:"box"`
+ Hour string `json:"hour"` // UTC, 2006-01-02-15
+ Samples []Sample `json:"samples"`
+}
+
+func HourOf(t time.Time) string // t.UTC().Format("2006-01-02-15")
+func RawKey(box, hour string) string // "metrics/boxes/" + box + "/raw/" + hour + ".json"
+func DayKey(box, date string) string // "metrics/boxes/" + box + "/days/" + date + ".json"
+func WriteRaw(ctx context.Context, s release.Store, c RawChunk) error
+func StaleRawKeys(keys []string, now time.Time, keep time.Duration) []string
+```
+
+`WriteRaw` uses the read-etag-then-Put overwrite pattern of `status.WriteBox` (`internal/status/snapshot.go:260-287`): Get current etag (missing → `""`), Put with it, on `release.ErrCASConflict` re-read and retry once, second conflict returns the error. `StaleRawKeys` is pure: parses the trailing `<hour>.json` of each key, returns keys whose hour-start is older than `now.Add(-keep)`; unparsable keys are **skipped, never returned** (never delete what you don't understand).
+
+- [ ] **Step 1: Failing tests:** round-trip `WriteRaw` to a `release.NewDirStore(t.TempDir())` then Get+Unmarshal and compare; overwrite same hour twice and verify one key, latest content wins; `TestStaleRawKeys`:
+
+```go
+func TestStaleRawKeys(t *testing.T) {
+ now := time.Date(2026, 8, 2, 22, 30, 0, 0, time.UTC)
+ keys := []string{
+ "metrics/boxes/b/raw/2026-07-31-10.json", // stale
+ "metrics/boxes/b/raw/2026-08-02-21.json", // fresh
+ "metrics/boxes/b/raw/garbage.json", // unparsable — never returned
+ }
+ got := StaleRawKeys(keys, now, 48*time.Hour)
+ if len(got) != 1 || got[0] != keys[0] {
+ t.Fatalf("got %v, want only the stale key", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run — FAIL. Step 3: Implement. Step 4: Run — PASS. Step 5: Commit** `metrics: raw chunk shapes, bucket keys, CAS overwrite, prune selection`
+
+---
+
+### Task 4: `internal/metrics` — hourly rollups
+
+**Files:**
+- Create: `internal/metrics/rollup.go`, `internal/metrics/rollup_test.go`
+
+**Interfaces (produces):**
+
+```go
+type Agg struct {
+ Min float64 `json:"min"`
+ Avg float64 `json:"avg"`
+ Max float64 `json:"max"`
+ N int `json:"n"` // samples that carried this metric (nil samples excluded)
+}
+type HourAgg struct {
+ Host map[string]Agg `json:"host"` // "cpu","mem","disk","load1"
+ Units map[string]map[string]Agg `json:"units"` // unit -> "cpu","mem_bytes" -> Agg
+}
+type DayRollup struct {
+ V int `json:"v"` // 1
+ Box string `json:"box"`
+ Date string `json:"date"` // UTC 2006-01-02
+ Hours map[string]HourAgg `json:"hours"` // "00".."23"
+}
+
+func RollupHour(samples []Sample) HourAgg // nil metrics excluded from N; metric absent everywhere -> key absent
+func UpsertRollup(ctx context.Context, s release.Store, box string, hour time.Time, agg HourAgg) error
+```
+
+`UpsertRollup` reads `DayKey(box, hour.UTC().Format("2006-01-02"))` (missing → fresh `DayRollup{V:1, Box: box, Date: ..., Hours: map[...]}`), sets `Hours[hour.UTC().Format("15")]`, writes back with the same CAS-retry-once pattern as `WriteRaw`. Unit rollup metrics: `"cpu"` from `UnitSample.CPUFrac`, `"mem_bytes"` from `MemBytes` (float64-cast). Host: `"cpu"`, `"mem"`, `"disk"` fractions and `"load1"`.
+
+- [ ] **Step 1: Failing tests:** `RollupHour` over 3 samples where one has nil CPU → `Host["cpu"].N == 2` with correct min/avg/max; a unit present in only 1 sample → its aggs have N=1; all-nil metric → key absent. `UpsertRollup` twice for hours 10 and 11 of the same day → one day object with two hour entries.
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `metrics: hourly min/avg/max rollups into day objects`
+
+---
+
+### Task 5: `internal/metrics` — alert rules, sustain, hysteresis
+
+**Files:**
+- Create: `internal/metrics/alert.go`, `internal/metrics/alert_test.go`
+
+**Interfaces (produces, consumed by Task 7 and the console in Tasks 9-11):**
+
+```go
+type Rules struct {
+ DiskFrac float64 // fire >, default 0.90, immediate
+ MemFrac float64 // default 0.90
+ MemSustain time.Duration // default 10 * time.Minute
+ CPUFrac float64 // default 0.95
+ CPUSustain time.Duration // default 15 * time.Minute
+ LagSecs int64 // default 900, immediate; inert while readings are nil
+ ClearBy float64 // hysteresis, default 0.05 (fractions); lag clears at LagSecs-300
+}
+func DefaultRules() Rules
+func RulesFromEnv(getenv func(string) string) Rules
+// env overrides (percent integers, matching the spec's host.env surface):
+// CARLOS_ALERT_DISK_PCT, CARLOS_ALERT_MEM_PCT, CARLOS_ALERT_CPU_PCT
+// unparsable or out-of-range (0..100) values keep the default (log at caller, not here — pure).
+
+type Observation struct {
+ At time.Time
+ CPUFrac *float64
+ MemFrac *float64
+ DiskFrac *float64
+ LagSecs *int64
+}
+type RuleState struct {
+ Firing bool `json:"firing"`
+ Since time.Time `json:"since,omitempty"` // continuously-above-threshold since (sustain tracking)
+ FiredAt time.Time `json:"fired_at,omitempty"`
+ Value float64 `json:"value,omitempty"` // reading at last transition
+}
+type AlertState struct {
+ V int `json:"v"` // 1
+ Box string `json:"box"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Rules map[string]RuleState `json:"rules"` // "disk","mem","cpu","lag"
+}
+type Event struct {
+ Rule string `json:"rule"`
+ Firing bool `json:"firing"` // true=fired, false=cleared
+ Value float64 `json:"value"`
+ Box string `json:"box"`
+}
+func Evaluate(r Rules, st AlertState, obs Observation) (AlertState, []Event)
+func AlertKey(box string) string // "alerts/boxes/" + box + ".json"
+```
+
+**Evaluate semantics (encode exactly):**
+- nil reading: state unchanged for that rule — no data never fires AND never clears (clearing needs evidence).
+- Not firing, reading > threshold: set `Since` if zero; fire (emit Event) when `obs.At.Sub(Since) >= sustain` (immediate rules: sustain 0 → fire same tick). Reading back at/below threshold before sustain elapses → reset `Since` to zero.
+- Firing, reading < threshold−ClearBy: clear (emit Event), zero `Since`/`FiredAt`.
+- Firing, reading between clear-line and threshold: stays firing (hysteresis band).
+
+- [ ] **Step 1: Failing tests** — table-driven walk of ticks:
+
+```go
+func TestEvaluateMemSustainAndHysteresis(t *testing.T) {
+ r := DefaultRules()
+ st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
+ t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
+ obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} }
+
+ st, ev := Evaluate(r, st, obs(0.95, t0)) // above, sustain starts — no event yet
+ if len(ev) != 0 || st.Rules["mem"].Firing {
+ t.Fatalf("must not fire before sustain: ev=%v firing=%v", ev, st.Rules["mem"].Firing)
+ }
+ st, ev = Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) // sustain met — fires
+ if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "mem" {
+ t.Fatalf("want fire event, got %v", ev)
+ }
+ st, ev = Evaluate(r, st, obs(0.88, t0.Add(11*time.Minute))) // hysteresis band (0.85..0.90) — stays firing
+ if len(ev) != 0 || !st.Rules["mem"].Firing {
+ t.Fatal("must hold inside hysteresis band")
+ }
+ st, ev = Evaluate(r, st, obs(0.80, t0.Add(12*time.Minute))) // below clear line — clears
+ if len(ev) != 1 || ev[0].Firing {
+ t.Fatalf("want clear event, got %v", ev)
+ }
+}
+```
+
+Plus: disk fires immediately at first tick > 0.90; sustain resets when a dip below threshold interrupts it; nil reading holds a firing alert firing and a quiet rule quiet; lag nil forever → no lag event ever; `RulesFromEnv` with a getenv built from a map (the `env := func(m map[string]string) func(string) string {...}` helper pattern at `internal/host/host_test.go:170-172`): `{"CARLOS_ALERT_DISK_PCT": "95"}` → DiskFrac 0.95, `"nonsense"` → default kept.
+
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `metrics: alert rules — sustain windows, hysteresis, nil never fires or clears`
+
+---
+
+### Task 6: `internal/host` — OS readers for the sampler
+
+**Files:**
+- Create: `internal/host/metrics_probes.go`
+- Test: parsing already covered in Task 2; this file is thin OS glue, untested by the same convention as `probes.go` readers (its doc comment must say so).
+
+**Interfaces (produces, consumed by Task 7):**
+
+```go
+// All readers fail toward nil / empty, mirroring probes.go.
+func readProcStat() *metrics.CPUCounters // os.ReadFile("/proc/stat") + metrics.ParseProcStat
+func readLoadAvg() (l1, l5, l15 *float64) // os.ReadFile("/proc/loadavg") + metrics.ParseLoadAvg
+const cgroupSliceDir = "/sys/fs/cgroup/system.slice"
+func listCarlosUnits() []string // ReadDir(cgroupSliceDir), names with prefix "carlos-" and suffix ".service"; nil on error
+type unitRaw struct {
+ cpuUsec *int64
+ memBytes *int64
+ memPeakBytes *int64
+ readBytes *int64
+ writeBytes *int64
+}
+func readUnitRaw(unit string) unitRaw // reads cpu.stat, memory.current, memory.peak, io.stat under cgroupSliceDir/<unit>/
+```
+
+- [ ] **Step 1: Implement** (no TDD cycle — parsers are tested; this is `os.ReadFile` + `filepath.Join` glue; each file read failure yields nil for that field only).
+- [ ] **Step 2: `go build ./... && go vet ./...`** — clean.
+- [ ] **Step 3: Commit** `host: cgroup and /proc readers for the metrics sampler`
+
+---
+
+### Task 7: `internal/host` — sampler loop, flush, rollup, prune, alerting, webhook
+
+**Files:**
+- Create: `internal/host/metrics_loop.go`
+- Modify: `internal/host/agent.go:147` (add `go metricsLoop(reg)` beside the other loops; reg is unused v1 but keeps the signature uniform — actually pass nothing: `go metricsLoop()`)
+- Test: `internal/host/metrics_loop_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1-6 (`metrics.*`, `release.Store`, readers), `status.Label(machineID)` (`internal/status/snapshot.go:157`) for the box name, `readMachineID()`/`readMemFrac()`/`readDiskFrac()` (`internal/host/probes.go`), `resolveStatusBucket` (`internal/host/agent.go:425`).
+- Produces (pure, the test seam):
+
+```go
+// sampleProbes is the injectable fixture bundle, like status.Probes.
+type sampleProbes struct {
+ procStat func() *metrics.CPUCounters
+ loadavg func() (l1, l5, l15 *float64)
+ memFrac func() *float64
+ diskFrac func() *float64
+ units func() map[string]unitRaw
+ now func() time.Time
+}
+// sampler carries the delta state between ticks.
+type sampler struct {
+ prevCPU *metrics.CPUCounters
+ prevUnit map[string]*int64 // unit -> cpu usec at previous tick
+ prevAt time.Time
+ buf []metrics.Sample
+}
+func (sm *sampler) tick(p sampleProbes) // appends one metrics.Sample to buf
+// metricsFlush writes buf as the current hour's chunk; on hour rollover it also
+// rolls up the PREVIOUS hour's samples, upserts the day rollup, prunes raw
+// chunks older than 48h (List + metrics.StaleRawKeys + Delete), and drops
+// flushed samples from buf.
+func metricsFlush(ctx context.Context, s release.Store, box string, sm *sampler, now time.Time) error
+// alertTick evaluates rules against the latest sample, persists state to
+// <dataDir>/monitor/alerts.json, publishes metrics.AlertKey(box) when state
+// changed, and posts each event to webhookURL ("" = skip).
+func alertTick(ctx context.Context, s release.Store, box string, rules metrics.Rules,
+ st *metrics.AlertState, latest metrics.Sample, statePath, webhookURL string,
+ post func(url string, body []byte) error) error
+```
+
+`metricsLoop()` itself is thin and untested (convention of `statusLoop`): resolve bucket via `resolveStatusBucket(os.Getenv)` (unset → one log line, return — inert until configured); `release.NewS3Store`; box = `status.Label(readMachineID())`; load persisted `AlertState` from `<dataDir()>/monitor/alerts.json` (missing/corrupt → fresh state, never crash); `metrics.RulesFromEnv(os.Getenv)`; webhook = `os.Getenv("CARLOS_ALERT_WEBHOOK")`; then a 60s ticker calling `sm.tick(realProbes())` + `alertTick(...)` every tick and `metricsFlush(...)` every 5th. The webhook `post` is a 5s-timeout `http.Post(url, "application/json", ...)`; non-2xx is an error (logged by caller, alert state keeps the event delivered=false? NO — v1: webhook is fire-and-forget per event, failures logged only; the console keymail path is the reliable channel).
+
+- [ ] **Step 1: Failing tests** (DirStore + fixtures, no OS):
+
+```go
+func TestSamplerTickAndFlush(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ at := time.Date(2026, 8, 2, 10, 59, 30, 0, time.UTC)
+ c0, c1 := &metrics.CPUCounters{Busy: 100, Total: 1000}, &metrics.CPUCounters{Busy: 160, Total: 1100}
+ mem, disk := 0.5, 0.3
+ usec0, usec1 := int64(0), int64(3_000_000)
+ sm := &sampler{}
+ probes := func(c *metrics.CPUCounters, usec int64, now time.Time) sampleProbes {
+ return sampleProbes{
+ procStat: func() *metrics.CPUCounters { return c },
+ loadavg: func() (*float64, *float64, *float64) { return nil, nil, nil },
+ memFrac: func() *float64 { return &mem },
+ diskFrac: func() *float64 { return &disk },
+ units: func() map[string]unitRaw {
+ return map[string]unitRaw{"carlos-app@a.example.com.service": {cpuUsec: &usec}}
+ },
+ now: func() time.Time { return now },
+ }
+ }
+ sm.tick(probes(c0, usec0, at))
+ sm.tick(probes(c1, usec1, at.Add(time.Minute)))
+ if len(sm.buf) != 2 {
+ t.Fatalf("buf=%d, want 2", len(sm.buf))
+ }
+ if sm.buf[0].Host.CPUFrac != nil {
+ t.Fatal("first tick has no delta — CPU must be nil, not 0")
+ }
+ if f := sm.buf[1].Host.CPUFrac; f == nil || math.Abs(*f-0.6) > 1e-9 {
+ t.Fatalf("second tick CPU=%v, want 0.6", f)
+ }
+ if f := sm.buf[1].Units["carlos-app@a.example.com.service"].CPUFrac; f == nil || math.Abs(*f-0.05) > 1e-9 {
+ t.Fatalf("unit CPU=%v, want 0.05 (3s of CPU over 60s)", f)
+ }
+
+ // Flush within the hour: one raw chunk, no rollup yet.
+ if err := metricsFlush(ctx, store, "box-a", sm, at.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := store.Get(ctx, "metrics/boxes/box-a/raw/2026-08-02-10.json"); err != nil {
+ t.Fatalf("raw chunk missing: %v", err)
+ }
+ // Tick past the hour boundary, flush again: previous hour rolled up.
+ sm.tick(probes(c1, usec1, at.Add(2*time.Minute))) // 11:01:30
+ if err := metricsFlush(ctx, store, "box-a", sm, at.Add(2*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := store.Get(ctx, "metrics/boxes/box-a/days/2026-08-02.json")
+ if err != nil {
+ t.Fatalf("day rollup missing: %v", err)
+ }
+ var day metrics.DayRollup
+ if err := json.Unmarshal(body, &day); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := day.Hours["10"]; !ok {
+ t.Fatalf("hour 10 not rolled up: %v", day.Hours)
+ }
+}
+```
+
+Plus: `TestMetricsFlushPrunesOldChunks` (seed a 3-day-old raw key, flush at an hour boundary, key gone; fresh key survives); `TestAlertTickPersistsPublishesAndPosts` (latest sample disk=0.95 → state file written under `t.TempDir()`, `alerts/boxes/box-a.json` in store parses to `AlertState` with disk firing, injected `post` captured exactly one body containing `"rule":"disk"`, and a second identical tick emits NO new post); `TestAlertTickWebhookFailureDoesNotBlock` (post returns error → alertTick returns nil, state still persisted/published).
+
+- [ ] **Step 2: FAIL. Step 3: Implement `metrics_loop.go`** and add `go metricsLoop()` in `RunAgent` (`agent.go:147` block). **Step 4: PASS, full `go test ./... -count=1`. Step 5: Commit** `host: metrics sampler loop — 60s ticks, 5min flush, hourly rollup+prune, on-box alerts`
+
+---
+
+### Task 8: Privacy contract test — needles never reach `status/`
+
+**Files:**
+- Modify: `internal/host/host_test.go`
+
+**Interfaces:** Consumes `statusTick` (`internal/host/agent.go:553`), `release.NewDirStore`, `metrics.WriteRaw`.
+
+- [ ] **Step 1: Write the test** (it should PASS immediately — it's a tripwire for future regressions, and MUST be committed with a comment saying exactly that):
+
+```go
+// TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the
+// monitoring spec commits to: operator-grade needles live under metrics/ and
+// alerts/, and nothing the public status publisher writes may ever be
+// derived from them. Sentinel values planted in metrics objects must not
+// appear anywhere under status/ after a full publish tick.
+func TestStatusPublishNeverReadsMetricsPrefixes(t *testing.T) {
+ ctx := context.Background()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ sentinel := 0.987654321
+ chunk := metrics.RawChunk{V: 1, Box: "box-a", Hour: "2026-08-02-10", Samples: []metrics.Sample{
+ {At: time.Now(), Host: metrics.HostSample{CPUFrac: &sentinel}},
+ }}
+ if err := metrics.WriteRaw(ctx, store, chunk); err != nil {
+ t.Fatal(err)
+ }
+ if err := statusTick(ctx, store, reg, status.Probes{MachineID: "m"}, true); err != nil {
+ t.Fatal(err)
+ }
+ keys, err := store.List(ctx, "status/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range keys {
+ body, _, err := store.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(body), "987654321") {
+ t.Errorf("%s contains the metrics sentinel — the status publisher read a metrics object", k)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run — PASS. Step 3: Commit** `host: contract test — status publish never derives from metrics/ or alerts/`
+
+---
+
+### Task 9: Console — operator list, alert watcher, keymail delivery
+
+**Files:**
+- Modify: `internal/console/store.go` (new query + migration), `internal/console/server.go` (watcher fields), `cmd/console/main.go:208-223` (start watcher in `serve()`)
+- Create: `internal/console/alertwatch.go`
+- Test: `internal/console/alertwatch_test.go`
+
+**Interfaces:**
+- Consumes: `Store.IsOperator` pattern (`store.go:1055`), `Store.GrantToken(address)` (`store.go:1081`), `Keymail.SendApp` (`keymail.go:238`), `s.cfg.Releases release.Store`, `metrics.AlertState`/`AlertKey`.
+- Produces:
+
+```go
+// store.go
+func (s *Store) Operators() ([]string, error) // SELECT address FROM users WHERE operator != 0 ORDER BY address
+// migration appended to the migrations list, same style as store.go:172:
+// CREATE TABLE IF NOT EXISTS alert_seen (box TEXT NOT NULL, rule TEXT NOT NULL,
+// firing INTEGER NOT NULL, PRIMARY KEY (box, rule));
+func (s *Store) AlertSeen(box, rule string) (firing bool, known bool, err error)
+func (s *Store) SetAlertSeen(box, rule string, firing bool) error
+
+// alertwatch.go
+// alertPass reads every alerts/boxes/*.json, diffs each rule's firing state
+// against alert_seen, and for each TRANSITION sends one keymail per operator
+// (via send, using that operator's own grant token) and records the new state.
+// A send failure for ANY operator leaves alert_seen unrecorded for that rule,
+// so the next pass retries the whole transition — matching the spec's
+// "retried on the next tick". Operators with no grant token are skipped
+// (logged once per pass, not fatal). Missing alerts/ prefix = nothing to do.
+func (s *Server) alertPass(ctx context.Context) error
+// StartAlertWatcher runs alertPass every minute until ctx is done. Called
+// from cmd/console serve(), NOT NewServer — tests construct servers without
+// background goroutines.
+func (s *Server) StartAlertWatcher(ctx context.Context)
+```
+
+`Server` gains one field: `sendApp func(ctx context.Context, token, to, subject, body string) (string, error)` — `NewServer` defaults it to `s.km.SendApp`; tests overwrite it. Message copy: subject `"[carlos] <box>: <rule> alert"` / `"[carlos] <box>: <rule> cleared"`, body includes rule, box, value, and UTC timestamp — plain sentences, no needles beyond the triggering value.
+
+- [ ] **Step 1: Failing tests:** seed a console `Store` (see `store_test.go` for the open-in-tempdir pattern) with two operator users, one holding a grant token; seed a DirStore with `alerts/boxes/box-a.json` marshalled from `metrics.AlertState{V:1, Box:"box-a", Rules: map[string]metrics.RuleState{"disk": {Firing: true, Value: 0.95}}}`; a captured `sendApp` func. Assert: first `alertPass` sends exactly 1 keymail (the token-holding operator) and records seen; second pass sends 0; flipping the stored state to cleared sends a "cleared" keymail; a failing `sendApp` leaves the transition unrecorded (next pass retries).
+- [ ] **Step 2: FAIL. Step 3: Implement** store methods + migration + `alertwatch.go` + `serve()` wiring (`go srv.StartAlertWatcher(context.Background())` after `NewServer`). **Step 4: PASS. Step 5: Commit** `console: alert watcher — keymail on fire/clear via operators' own grants`
+
+---
+
+### Task 10: Console — metrics view assembly (bucket reads + cache)
+
+**Files:**
+- Create: `internal/console/metricsview.go`, `internal/console/metricsview_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store`, `metrics` types, `Assemble`'s route data for host→app mapping (Task 12 uses that part).
+- Produces (consumed by Tasks 11-12):
+
+```go
+type UnitRow struct {
+ Unit string // full unit name
+ CPUFrac *float64
+ MemBytes *int64
+ ReadBytes, WriteBytes *int64
+}
+type BoxMetricsView struct {
+ Box string
+ At time.Time // latest sample time
+ CPU, Mem, Disk *float64
+ SparkCPU, SparkMem, SparkDisk string // SVG polyline points, "" when no data
+ Units []UnitRow // sorted by CPUFrac desc, nil-CPU rows last
+}
+type AlertRow struct {
+ Box, Rule string
+ Value float64
+ FiredAt time.Time
+}
+// LoadMetrics reads every box under metrics/boxes/, the raw chunks of the
+// last 48 hours (missing hours skipped silently), and alerts/boxes/*.json.
+// Results are cached in-process for 60s (all callers share one bucket sweep).
+func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, error)
+// sparkPoints maps up to 48h of minute samples into an SVG polyline points
+// string on a 240x40 viewBox, nil samples breaking the line into gaps is NOT
+// attempted in v1 — nil values are simply skipped (documented in the comment).
+func sparkPoints(vals []*float64, w, h int) string
+```
+
+Cache: unexported `metricsCache struct{ mu sync.Mutex; at time.Time; boxes []BoxMetricsView; alerts []AlertRow }` field on `Server`; TTL 60s; a failed sweep is never cached. Box discovery: `List(ctx, "metrics/boxes/")` and take the path segment after `boxes/`. Per box read `RawKey(box, HourOf(h))` for each of the last 48 UTC hours; `os.ErrNotExist` → skip.
+
+- [ ] **Step 1: Failing tests:** DirStore seeded with 2 hour-chunks for one box (via `metrics.WriteRaw`) + one alert object; construct `Server` the way existing console tests do (see `server_test.go` for the minimal `Config`); assert `LoadMetrics` returns 1 box with latest values from the newest sample, non-empty `SparkCPU`, units sorted CPU-desc, 1 `AlertRow`; assert a second call within TTL does not re-read (poison the store after first call — e.g. delete the objects — and assert same result returned). `sparkPoints([]*float64{p(0.1), nil, p(0.9)}, 240, 40)` → two coordinate pairs, y inverted (higher value = smaller y).
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `console: metrics view assembly — 48h sweep, 60s cache, server-side sparklines`
+
+---
+
+### Task 11: Console — operator metrics dashboard page
+
+**Files:**
+- Modify: `internal/console/server.go` (route + handler + `page` struct fields), `internal/console/templates/layout.html` only if a nav link for operators exists for /fleet (mirror however /fleet is linked; if it isn't, don't add one)
+- Create: `internal/console/templates/metrics.html`
+- Test: `internal/console/server_test.go` additions
+
+**Interfaces:**
+- Consumes: `LoadMetrics` (Task 10), operator gate pattern from `handleFleet` (`server.go:888-912`), `s.render` (`server.go:258`).
+- Produces: `GET /fleet/metrics` → `handleFleetMetrics`; `page` gains `Metrics []BoxMetricsView` and `MetricAlerts []AlertRow`.
+
+Handler (copy `handleFleet`'s gate EXACTLY — every failure path is plain `http.NotFound`, no styled 404):
+
+```go
+func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: fleet metrics: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ boxes, alerts, err := s.LoadMetrics(r.Context())
+ if err != nil {
+ log.Printf("console: fleet metrics: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Metrics could not be read.")
+ return
+ }
+ s.render(w, http.StatusOK, "metrics", page{Title: "Fleet metrics", Address: addr, CSRF: raw, Metrics: boxes, MetricAlerts: alerts})
+}
+```
+
+Template `metrics.html` (`{{define "metrics"}}{{template "top" .}} ... {{template "bottom" .}}{{end}}`): alerts section first (red, only when non-empty), then one card per box — headline numbers (`{{printf "%.0f%%" ...}}` via a small `pct` template func added beside `strOrDash` in `server.go:33-77`, nil → "—"), three labelled inline sparklines (`<svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>`), then the per-unit table (Unit / CPU / Memory / IO), nil cells rendered "—" never "0".
+
+- [ ] **Step 1: Failing tests:** non-operator session GETs `/fleet/metrics` → 404 byte-identical to an unrouted URL (compare against a GET of `/definitely-not-a-route`); operator session → 200 containing a seeded box label and a seeded unit name; signed-out → 404.
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `console: operator metrics dashboard — needles, sparklines, noisy-neighbour table`
+
+---
+
+### Task 12: Console — owner-facing app metrics on the app page
+
+**Files:**
+- Modify: `internal/console/server.go` (`handleApp` at :1010-1041, `page` struct), `internal/console/metricsview.go`, `internal/console/templates/app.html`
+- Test: `internal/console/server_test.go` additions
+
+**Interfaces:**
+- Consumes: `handleApp`'s existing `accountApp` gate + `Assemble` output (`AppView.Routes[].Host` — `view.go:171`), `activator.UnitName(host)` (`internal/activator/backend_unit.go:65`), the Task-10 cache.
+- Produces:
+
+```go
+type InstanceMetrics struct {
+ Host string // the route host — how owners name their instances
+ Box string
+ CPUFrac *float64
+ MemBytes *int64
+ SparkCPU, SparkMem string
+}
+// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the units
+// carlos-app@<host>.service for the given hosts. It returns unit-level rows
+// only — no host totals, no other units, no alert data. The allowlist is the
+// hosts slice the caller derived from the app's OWN routes; nothing else can
+// enter the view.
+func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]InstanceMetrics, error)
+```
+
+`page` gains `AppMetrics []InstanceMetrics`. In `handleApp`, after the existing `Assemble` call, collect `hosts` from the app's own `views[0].Routes` and populate `AppMetrics` (errors logged, page renders without metrics — metrics must never break the app page). `app.html` gains a "Resources" section: per instance-host, CPU/memory numbers + two sparklines; absent (hibernating) instances render "asleep — no recent readings", never zeros.
+
+- [ ] **Step 1: Failing tests:**
+ - Seed metrics for units `carlos-app@owned.example.com.service` AND `carlos-app@other-tenant.example.com.service` on one box. An account member's GET of their app page (owning only `owned.example.com`) → 200 containing `owned.example.com` metrics markup and NOT containing the string `other-tenant` anywhere in the body.
+ - The app page contains no host-level needle: assert the body does not contain the box's host CPU sentinel value seeded distinctively (e.g. host CPU 0.777 → "77.7" absent; pick sentinel values that cannot collide with unit numbers).
+ - `OwnerAppMetrics` with empty hosts → empty, no error.
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `console: owner app metrics — own units only, strict boundary`
+
+---
+
+### Task 13: Docs, full verification, review, ship
+
+**Files:**
+- Modify: `README.md` (if it documents env vars/agent loops — add `CARLOS_ALERT_*` and the metrics/alert bucket prefixes where the status page's surface is documented), `docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md` (only if implementation deviated — record deviations, don't rewrite history)
+
+- [ ] **Step 1:** `gofmt -l` on all touched files → empty; `go build ./... && go vet ./... && go test ./... -count=1` → all clean (expect 15 packages now with `internal/metrics`).
+- [ ] **Step 2:** Dispatch an **Opus** reviewer subagent (CLAUDE.md delegation rule — this touches production paths and a security-sensitive console surface) over the full branch diff, with the spec and this plan as context. Fix everything it finds at Critical/Important; use judgment on nits.
+- [ ] **Step 3:** Re-run the full gate after fixes.
+- [ ] **Step 4:** Push branch, open draft PR titled `monitoring: host & process metrics, alerts, operator + owner dashboards` with a body summarizing spec, storage shapes, and the two delivery paths, ending with the standard attribution footer.
+- [ ] **Step 5:** Deploy: nothing is in production yet and deploys are pre-approved. If the pinfra checkout / ship scripts are reachable from this environment, ship + promote per `scripts/ship-app.sh` conventions; otherwise document exact deploy steps (bootstrap unchanged — same units; the new loops arm via env vars: `CARLOS_ALERT_WEBHOOK` optional, thresholds optional, metrics loop arms automatically wherever the status loop is already armed) in the PR body.