# REPORT — v0.139.0: immediate out-of-cycle hub report on user actions (Direction 1) **Date:** 2026-07-16 · **Session:** Claude Code (spec: "Immediate out-of-cycle hub report on user actions (Direction 1)") · **Repo:** felhom-controller only (hub/agent/catalog untouched, as specced) ## Baselines used | Repo | Base commit | Version before → after | |---|---|---| | felhom-controller | `8f3564c` (matched the spec's `8f3564c1`) | v0.138.0 → **v0.139.0** | ## What shipped One generic, debounced out-of-cycle report trigger (`report.Trigger`) on the proven outbound push channel; the 15-min `hub-report` cycle untouched as the reconciliation backbone. - **`controller/internal/report/trigger.go` (new):** buffered-1 signal chan + single worker (shape: hub `wgsync/reconciler.go`). `Fire()` non-blocking; worker = quiet 2 s → drain → min-interval 15 s → ONE fire of an opaque `func() error`. Coalesce-and-eventually-fire (trailing edge) — deliberately NOT the `internal/sync` refuse-debounce. No own retries (Pusher owns 3×5 s); fire error → one WARN, loop continues; exits on ctx cancel. - **`controller/cmd/controller/main.go`:** ONE canonical fire closure (BuildReport + Claimed + Push), built only when `hubPusher != nil && cfg.Hub.Enabled`; `trigger.Run` under the process ctx; replaced the raw per-fire goroutine behind `apiRouter.SetReportPushTrigger` (v0.70.0 geo seam kept, now debounced); new `webServer.SetReportTrigger(trigger.Fire)`. - **`controller/internal/api/router.go`:** `reportPushNow()` added after success in deploy, remove, delete (geo save/sync already had it). - **`controller/internal/web`:** `SetReportTrigger`/`reportTriggerNow` seam (server.go, nil-safe, mirrors `api.Router.reportPushNow`); fired AFTER the successful local commit in: escrow recovery-code claim (escrow_handlers.go — the headline), notification-prefs save + app-email toggle (handlers.go; password handler untouched — it is a separate handler), offsite target config (offbox_handlers.go `offboxConfigHandler`) **and** per-app offsite toggle (`offboxToggleHandler`), customer claim completion (claim.go). - Docs: CHANGELOG (v0.139.0 top entry), CONTEXT.md decision ("Direction 1 shipped; Direction 2 pending SPIKE-immediate-sync-transport"), README report section rewritten, REUSE.md (§1 helper row + §2 coalescing-vs-refuse pattern pair). ### Handler-name mapping vs the spec (per §Part 3 "wire the real one") | Spec name | Real handler wired | |---|---| | "settings POST handler(s) in web/handlers.go" | `settingsNotificationsHandler` + `settingsAppEmailHandler` (both preference/notification-type; `settingsPasswordHandler` is a separate handler → cleanly excluded) | | "offbox_handlers.go toggle handler" | BOTH `offboxConfigHandler` (target enable/disable) and `offboxToggleHandler` (per-app Távoli-mentés flag) — the spec's "enable/disable" is the config handler; the literal "toggle handler" is per-app; both are hub-visible state | | "api-router deploy/remove endpoints" | `deployStack`, `removeStack`, `deleteStack` (both removal variants) | ## Commits | Hash | Content | |---|---| | `fe9266f` | feat(report): v0.139.0 — trigger engine + all wiring + tests + docs (15 files, +501/−14) | | (this commit) | docs(report): REPORT.md live-validation evidence | ## Tests & red-proofs Green gate `go build ./... && go vet ./... && go test ./...` — all packages pass **except 6 pre-existing Windows-environment failures** (appexport disk-free df=0 ×4, stacks `TestMigrate_PaperlessShape_ScopeApp`, web `TestFab_SelectionsRideBothStartPipelines`) — **verified failing identically on base `8f3564c`** via `git stash` + rerun. Changed packages: `internal/report` ok, `internal/api` ok, `internal/web` ok (except the listed pre-existing fab test). Design gates (template_id, emoji, native_confirm, offbox_rename): all OK. Test functions: 629 total after (+8 new). New tests (`internal/report/trigger_test.go`, `web/report_trigger_seam_test.go`, `api/report_trigger_nilsafe_test.go`): | Group | Test | Result | |---|---|---| | A | `TestTrigger_SingleFireExactlyOnePush` (1 fire → exactly 1 push) | PASS | | A | `TestTrigger_FireNonBlocking` (100× Fire < 1 ms each, buffer full, no worker) | PASS | | B | `TestTrigger_BurstCoalescesTrailingEdge` (10 fires → 1..2 pushes, last push after last fire) | PASS | | C | `TestTrigger_FireErrorWorkerContinues` (error isolated, next fire pushes) | PASS | | §8 | `TestTrigger_CancelDuringWaitExitsPromptly` (<1 s exit mid-wait, 0 fires) | PASS | | D | `TestReportTriggerNow_NilSeamIsNoOp` (web) + `TestReportPushNow_NilSeamIsNoOp` (api) | PASS | | — | `TestReportTriggerNow_FiresAfterSuccessfulCommitOnly` (real offbox-toggle handler: 0 on refused save, 1 on committed save) | PASS | **Red-proof B (run→fail→revert):** replaced `Run` with the naive `for { <-signal; fire() }` loop (every signal straight to fire) → `TestTrigger_BurstCoalescesTrailingEdge` FAILED with **"10-fire burst produced 10 pushes, want 1..2"** → reverted → green. **Red-proof C (run→fail→revert):** added `return` after the fire-error WARN (worker dies on first error) → `TestTrigger_FireErrorWorkerContinues` FAILED with **"fire count = 1, want >= 2"** (the subsequent fire never pushed) → reverted → green. ## Deploy (guest 9201, bootstrap flow) - Built + pushed `gitea.dooplex.hu/admin/felhom-controller:0.139.0` on 180 (digest `73fb3f57…`, 145 MB). - Deployed: pull + `/etc/felhom-controller-image` + `systemctl restart felhom-controller-bootstrap.service`. - Verified: `docker ps` → `…felhom-controller:0.139.0 Up … (healthy)`; startup clean; startup report `17:34:04Z` "Hub report pushed successfully (2953 bytes)" ↔ hub pod `19:34:04 Received report from demo-felhom (2953 bytes)` (hub logs are CEST). ## Live validation (real-UI-flow; method stated per doctrine) **Method:** browser login was not possible (dashboard password is customer-owned; the deploy also invalidated sessions, and entering credentials via browser automation is out of bounds for CC), so per the repo doctrine the **exact endpoints the UI invokes** were driven server-side from 180 with curl — real login POST → session cookie → CSRF token from the real page → real form POSTs. The password was read into a shell var from Viktor's out-of-band store (never echoed/logged; length 13 only). Residual gap vs a browser: client-side rendering only — no server logic skipped. **Action:** `bookstack` per-app Távoli-mentés toggle OFF → (6 s later) → ON — value-identical end state (`offbox: true` confirmed in settings.json afterwards); both POSTs 302 (success flash). | Event | Controller (UTC) | Hub pod (CEST) | Delta | |---|---|---|---| | Toggle OFF POST | 17:42:17 | — | — | | Out-of-cycle push #1 | **17:42:19** "Hub report pushed successfully (2968 bytes)" | **19:42:19** "Received report from demo-felhom (2968 bytes)" | **2 s after the save** (= quiet window) | | Toggle ON POST | 17:42:23 | — | — | | Out-of-cycle push #2 (trailing) | **17:42:34** (2961 bytes) | **19:42:34** (2961 bytes) | 15 s after push #1 (= min-interval pacing; last state carried) | Round-trip user action → hub: **2 seconds** (previously up to ~15 min). The second push also live-demonstrates Scenario B's coalescing/pacing (two fires 6 s apart → exactly 2 pushes, 15 s spaced, trailing edge). **Scheduled cycle unaffected:** the next 15-min `hub-report` tick fired on schedule at **17:49:00Z** (≈15 min after the 17:34:04Z startup report; the two out-of-cycle pushes at 17:42 did NOT perturb it) — controller "Hub report pushed successfully (2982 bytes)" ↔ hub `19:49:00 Received report from demo-felhom (2982 bytes)`. ## NOT yet live-validated (unit-covered; shared seam proven live) - **Escrow recovery-code claim call site** — deliberately NOT exercised (the demo box is escrowed; un/re-escrowing is Viktor's supervised flow). Covered by unit tests + the identical `reportTriggerNow` seam proven live via the offbox toggle. Expected effect at Viktor's next wizard pass: the "megerősítésre vár" card flips in seconds. - **App deploy/remove call sites** — not exercised live (a deploy/remove on the demo box is not harmless); same `reportPushNow` seam as geo, now trigger-backed. - **Customer claim + notification-prefs + app-email call sites** — not exercised live (claim would reset Viktor's password; the notifications form on this box has an empty stored email — an "identical re-save" is impossible without picking an email, and the v0.137.0 wipe guard refuses empty-email-with-events anyway). ## Observations (out of scope — documented, not acted on) - **BuildReport call-site duplication:** the `BuildReport(...) + r.Claimed = sett.GetClaimed()` pair still exists at the scheduled job, the startup report, and the debug callback (per spec §Part 2.1 only the trigger closure was centralized). - **Copy soften candidate (post-live-proof):** `backups_remote.html` "legfeljebb 15 perc" → "általában néhány másodperc" is now a truthful later one-liner (spec §Part 3 note). - **`offboxConfirmEscrowHandler` (legacy manual escrow confirm)** also flips hub-relevant state but was not in the Viktor-approved wiring list — a candidate for a later one-liner. - **Windows green gate:** 6 pre-existing environment failures (df=0/rsync-shape); the Linux build on 180 is the authoritative gate for those packages.