feat(report): v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1)

New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s,
trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push.
One canonical fire closure in main.go; wired: geo save/sync + app
deploy/remove/delete (api reportPushNow), escrow recovery-code claim,
notification-prefs save, app-email toggle, offsite config + per-app
toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only
after a successful local commit). 15-min hub-report cycle untouched as
the reconciliation backbone; hub.enabled=false stays a strict no-op.
Tests: trigger engine (2 red-proofs), seam fires-after-commit-only,
nil-seam no-ops.
This commit is contained in:
2026-07-16 19:31:19 +02:00
parent 8f3564c137
commit fe9266f53f
15 changed files with 501 additions and 14 deletions
+3
View File
@@ -349,6 +349,9 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
if err := s.settings.SetClaimed(); err != nil {
s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err)
}
// v0.139.0: report out-of-cycle so the hub's Claimed flag (and claim-code consumption)
// flips in seconds — the operator sees the customer claim land immediately.
s.reportTriggerNow()
s.claimClearFailures(ip)
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
@@ -284,6 +284,10 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
}); err != nil {
s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err)
}
// v0.139.0: the blob is already uploaded at claim time — an immediate report lets the
// hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged)
// instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle.
s.reportTriggerNow()
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code
+4
View File
@@ -1491,6 +1491,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
}
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
// Sync preferences to hub
data := s.notificationsPageData()
@@ -1521,6 +1522,9 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
s.executeTemplate(w, r, "settings_notifications", data)
return
}
// v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the
// setting) — report out-of-cycle so the hub sees it in seconds.
s.reportTriggerNow()
// Reconcile the shim's running state with the new toggle.
if s.mailShim != nil {
if err := s.mailShim.Apply(enabled); err != nil {
@@ -112,6 +112,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
}
@@ -193,6 +194,7 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
return
}
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
}
@@ -0,0 +1,56 @@
package web
import (
"io"
"log"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Group D (Scenario D): with the seam unset (hub reporting off / nil trigger), the helper
// is a strict nil-safe no-op — a handler calling it must never panic.
func TestReportTriggerNow_NilSeamIsNoOp(t *testing.T) {
s := &Server{}
s.reportTriggerNow() // must not panic with reportTriggerFn == nil
}
// The wired seam fires on a SUCCESSFUL local commit and does NOT fire on the error path
// (§Part 3: after the successful local commit — never before, never on error). Exercised
// through a real handler (offbox per-app toggle) so deleting the call site fails this test.
func TestReportTriggerNow_FiresAfterSuccessfulCommitOnly(t *testing.T) {
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
fired := 0
s := &Server{settings: sett, logger: lg}
s.SetReportTrigger(func() { fired++ })
// Error path: missing app param → refused, trigger must NOT fire.
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"enabled": {"on"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.offboxToggleHandler(w, req)
if fired != 0 {
t.Fatalf("trigger fired %d time(s) on the refused save, want 0 (never on the error path)", fired)
}
// Success path: the setting commits → the trigger fires exactly once.
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"app": {"immich"}, "enabled": {"on"}}.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.offboxToggleHandler(w2, req2)
if !sett.IsAppOffbox("immich") {
t.Fatal("toggle did not commit — precondition for the trigger assertion")
}
if fired != 1 {
t.Fatalf("trigger fired %d time(s) after the successful save, want exactly 1", fired)
}
}
+23
View File
@@ -71,6 +71,13 @@ type Server struct {
// Hub push status callback — set via SetHubPushStatus for monitoring page
hubPushStatusFn func() HubPushStatusData
// Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to
// report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local
// commit (escrow claim, notification/app-email save, offsite config/toggle, customer
// claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle.
// nil (hub reporting off / tests) = strict no-op.
reportTriggerFn func()
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
wipeStagedEscrowFn func(ctx context.Context) error
@@ -235,6 +242,22 @@ func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
s.assetsSyncer = as
}
// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The
// provided func MUST be non-blocking — it is called from request handlers (the trigger's
// worker does the waiting/pushing). Init-time only, like every Set* here.
func (s *Server) SetReportTrigger(fn func()) {
s.reportTriggerFn = fn
}
// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors
// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never
// before it, never on an error path. Nil-safe no-op when hub reporting is off.
func (s *Server) reportTriggerNow() {
if s.reportTriggerFn != nil {
s.reportTriggerFn()
}
}
// SetIntegrationManager sets the app-to-app integration manager.
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
s.integrationMgr.Store(mgr)