Out-of-cycle pushes at 17:42:19/17:42:34Z (quiet-window + min-interval pacing exactly as designed, trailing edge carried the final state); scheduled 15-min cycle unaffected (17:49:00Z tick on both ends). Red-proof outcomes and the NOT-yet-live-validated list recorded.
9.3 KiB
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: hubwgsync/reconciler.go).Fire()non-blocking; worker = quiet 2 s → drain → min-interval 15 s → ONE fire of an opaquefunc() error. Coalesce-and-eventually-fire (trailing edge) — deliberately NOT theinternal/syncrefuse-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 whenhubPusher != nil && cfg.Hub.Enabled;trigger.Rununder the process ctx; replaced the raw per-fire goroutine behindapiRouter.SetReportPushTrigger(v0.70.0 geo seam kept, now debounced); newwebServer.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/reportTriggerNowseam (server.go, nil-safe, mirrorsapi.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.gooffboxConfigHandler) 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.0on 180 (digest73fb3f57…, 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 report17:34:04Z"Hub report pushed successfully (2953 bytes)" ↔ hub pod19: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
reportTriggerNowseam 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
reportPushNowseam 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.