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
+1 -1
View File
@@ -1778,7 +1778,7 @@ Periodic JSON push (default every 15 min) to the central felhom-hub service:
Bearer token authentication, 3-attempt retry with 5-second backoff. Push status tracked via `PushStatus` struct (LastAttempt, LastSuccess, LastError, consecutive failures) — used by the monitoring page and alert system to show Hub connection health.
**Immediate report push on geo change (v0.70.0):** besides the periodic cycle, a successful geo settings save and a successful manual geo sync fire an **out-of-band, non-blocking** report push (`Router.reportPushNow`, wired in `main.go` to `BuildReport`+`Push` in a goroutine), so the Hub reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the next ~15-min cycle. Currently scoped to the geo handlers; the same seam can be reused for other settings later.
**Immediate out-of-cycle report on user actions (v0.139.0, generalizing the v0.70.0 geo push):** besides the periodic cycle, user actions with hub-side effects fire a **debounced, coalescing out-of-cycle report push** (`report.Trigger` in `internal/report/trigger.go`: buffered-1 signal channel + single worker; quiet window 2 s, min spacing 15 s, trailing-edge — a burst coalesces to ≤ 1 + ceil(burst/15 s) pushes and the LAST state always reaches the Hub). One canonical fire closure in `main.go` does the full `BuildReport`+`Claimed`+`Push`; the trigger adds NO retry of its own (the Pusher owns retries) and every failure degrades to the 15-min cycle, which stays the reconciliation backbone. Wired call sites: geo settings save/manual sync + app deploy/remove/delete (`api.Router.reportPushNow`), and via the `web.Server.SetReportTrigger` seam (`reportTriggerNow`, fired only AFTER a successful local commit): escrow recovery-code claim (the ACK hash-match flips pending→escrowed in seconds), notification-prefs save, app-email toggle, offsite target config + per-app offsite toggle, customer claim completion. `hub.enabled: false`the seams stay nil (strict no-op).
#### Config apply + self-restart (`internal/api/router.go`, `internal/api/selfrestart.go`)
+27 -11
View File
@@ -795,20 +795,30 @@ func main() {
alertMgr.Refresh(report, cfg, backupMgr, false, "")
}()
// --- Out-of-cycle report trigger (v0.139.0, Direction 1) ---
// ONE canonical fire closure (full BuildReport + Claimed + Push) behind a debounced,
// coalescing trigger — user actions with hub-side effects (geo, escrow claim, settings
// save, offsite toggle, app deploy/remove, customer claim) round-trip in seconds instead
// of the next ~15-min cycle. The scheduled hub-report job above stays the reconciliation
// backbone; the trigger is best-effort on top (its failures degrade to the cycle).
// nil when hub reporting is off → the api/web seams stay unset (strict no-op).
var reportTrigger *report.Trigger
if hubPusher != nil && cfg.Hub.Enabled {
fireReport := func() error {
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
return hubPusher.Push(rep)
}
reportTrigger = report.NewTrigger(fireReport, logger)
go reportTrigger.Run(ctx)
}
// --- Initialize API router ---
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger)
if hubPusher != nil {
// Out-of-band, non-blocking hub report push (e.g. after a geo settings change) so
if reportTrigger != nil {
// Out-of-cycle, non-blocking hub report push (geo changes + app deploy/remove) so
// the hub reflects the new state immediately instead of after the next ~15-min cycle.
apiRouter.SetReportPushTrigger(func() {
go func() {
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
if err := hubPusher.Push(rep); err != nil {
logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err)
}
}()
})
apiRouter.SetReportPushTrigger(reportTrigger.Fire)
}
if assetsSyncer != nil {
apiRouter.SetAssetsSyncer(assetsSyncer)
@@ -883,6 +893,12 @@ func main() {
webServer.SetEscrowStale(escrowConfirmer.StaleBlob)
}
webServer.SetIntegrationManager(integrationMgr)
if reportTrigger != nil {
// Out-of-cycle report push after hub-relevant user actions (escrow claim, settings
// save, offsite config/toggle, customer claim) — same debounced trigger as the api
// router's; nil (hub reporting off) leaves the seam a strict no-op.
webServer.SetReportTrigger(reportTrigger.Fire)
}
if quiesceLoop != nil {
webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop
}
@@ -0,0 +1,11 @@
package api
import "testing"
// Group D (Scenario D, v0.139.0): with the seam unset (hub reporting disabled → main.go
// never calls SetReportPushTrigger), reportPushNow is a strict nil-safe no-op — the
// deploy/remove/geo handlers calling it must never panic.
func TestReportPushNow_NilSeamIsNoOp(t *testing.T) {
r := &Router{}
r.reportPushNow() // must not panic with triggerReportPush == nil
}
+9
View File
@@ -476,6 +476,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
go r.OnGeoRelevantChange()
}
// v0.139.0: the hub sees the deploy in seconds (debounced trigger, not per-request)
r.reportPushNow()
// Re-apply integrations that target this newly deployed stack
if r.integrationMgr != nil {
go r.integrationMgr.OnStackStart(context.Background(), name)
@@ -774,6 +777,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
if r.OnGeoRelevantChange != nil {
go r.OnGeoRelevantChange()
}
// v0.139.0: the hub sees the removal in seconds (debounced trigger, not per-request)
r.reportPushNow()
}
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
@@ -805,6 +811,9 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
// v0.139.0: the hub sees the delete in seconds (debounced trigger, not per-request)
r.reportPushNow()
}
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
+124
View File
@@ -0,0 +1,124 @@
// trigger.go — the generic, debounced out-of-cycle report trigger (v0.139.0, Direction 1).
//
// User actions with hub-side effects (escrow claim, settings save, offsite toggle, app
// deploy/remove, customer claim) fire this trigger so the hub sees the new state in seconds
// instead of after the next ~15-min hub-report cycle. The scheduled cycle stays the
// reconciliation backbone — the trigger is best-effort on top, and every failure degrades
// to the cycle (no retry loop of its own; the Pusher owns retries).
//
// Semantics: COALESCE AND EVENTUALLY FIRE (trailing edge). A burst of fires collapses into
// at most 1 + ceil(burst/minInterval) pushes, and the LAST push always happens after the
// last fire — never a refused/lost update (the internal/sync refuse-debounce is the wrong
// shape here on purpose). Shape copied from felhom.eu/hub/internal/wgsync/reconciler.go
// (buffered-1 trigger channel + worker loop).
package report
import (
"context"
"log"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
const (
// triggerQuietWindow is how long the worker waits after the first fire before pushing,
// so a burst of related saves (multi-field form, wizard steps) coalesces into one report.
triggerQuietWindow = 2 * time.Second
// triggerMinInterval is the minimum spacing between out-of-cycle pushes — the hard
// ceiling against report storms (pushes <= 1 + ceil(burst/minInterval) per burst).
triggerMinInterval = 15 * time.Second
)
// Trigger coalesces "push a report now" requests into paced calls of an opaque fire func.
// Fire() is safe from any goroutine and never blocks (HTTP handlers call it); the single
// worker (Run) does the waiting and the pushing. Report building stays with the caller —
// the fire closure in main.go does BuildReport + Pusher.Push.
type Trigger struct {
fire func() error
quiet time.Duration
minInterval time.Duration
signal chan struct{}
logger *log.Logger
}
// NewTrigger builds a Trigger with the production pacing. fire is the full report
// build+push closure; it must be safe to call repeatedly from one goroutine.
func NewTrigger(fire func() error, logger *log.Logger) *Trigger {
return newTriggerWithPacing(fire, triggerQuietWindow, triggerMinInterval, logger)
}
// newTriggerWithPacing is the test constructor — tests shrink the windows to keep runs fast.
func newTriggerWithPacing(fire func() error, quiet, minInterval time.Duration, logger *log.Logger) *Trigger {
return &Trigger{
fire: fire,
quiet: quiet,
minInterval: minInterval,
signal: make(chan struct{}, 1),
logger: logger,
}
}
// Fire requests an out-of-cycle report push. Non-blocking: a pending signal already covers
// this request (the eventual push carries the FULL current state either way).
func (t *Trigger) Fire() {
select {
case t.signal <- struct{}{}:
default:
}
}
// Run is the worker loop; main.go starts it under the process context. Per signal:
// wait the quiet window (coalescing the burst), drain, enforce minInterval spacing since
// the last push, drain again, then fire ONCE. A fire error is logged and the loop
// continues — the scheduled cycle reconciles. Exits on context cancel (a pending fire may
// be dropped then; the cycle covers it).
func (t *Trigger) Run(ctx context.Context) {
var lastPush time.Time
for {
select {
case <-ctx.Done():
return
case <-t.signal:
}
// Quiet window: let the burst finish, then collapse it into one push.
if !t.sleep(ctx, t.quiet) {
return
}
t.drain()
// Pacing: never push more often than minInterval. time.Since(zero) is huge,
// so the first push after startup is never delayed.
if remaining := t.minInterval - time.Since(lastPush); remaining > 0 {
if !t.sleep(ctx, remaining) {
return
}
t.drain()
}
if err := t.fire(); err != nil {
logx.Warnf(t.logger, "[report] out-of-cycle push failed: %v — next cycle reconciles", err)
}
lastPush = time.Now()
}
}
// drain clears a pending signal that arrived during a wait — those requests are covered
// by the push about to happen (full state, not deltas).
func (t *Trigger) drain() {
select {
case <-t.signal:
default:
}
}
// sleep waits d or until ctx is cancelled; false = cancelled (caller returns promptly,
// never blocking shutdown on a pending wait).
func (t *Trigger) sleep(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
+182
View File
@@ -0,0 +1,182 @@
package report
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// fireRecorder is the fake fire func: counts calls, records their times, and returns a
// settable error — the trigger tests never touch HTTP (felhom-testing doctrine).
type fireRecorder struct {
mu sync.Mutex
calls []time.Time
err error
}
func (f *fireRecorder) fire() error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, time.Now())
return f.err
}
func (f *fireRecorder) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func (f *fireRecorder) lastCall() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.calls) == 0 {
return time.Time{}
}
return f.calls[len(f.calls)-1]
}
func (f *fireRecorder) setErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.err = err
}
// startTrigger runs tr.Run under a test-scoped context and returns a done channel that
// closes when the worker exits.
func startTrigger(t *testing.T, tr *Trigger) chan struct{} {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
done := make(chan struct{})
go func() {
tr.Run(ctx)
close(done)
}()
return done
}
// waitForCount polls until the recorder reaches want calls or the deadline passes.
func waitForCount(t *testing.T, rec *fireRecorder, want int, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if rec.count() >= want {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("fire count = %d, want >= %d within %s", rec.count(), want, within)
}
// Group A (Scenario A): a single fire produces exactly ONE push, within quiet + ε.
func TestTrigger_SingleFireExactlyOnePush(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
startTrigger(t, tr)
tr.Fire()
waitForCount(t, rec, 1, 2*time.Second)
// Exactly one: no second push may appear (a duplicate would double-report for nothing).
time.Sleep(600 * time.Millisecond) // > quiet + minInterval
if got := rec.count(); got != 1 {
t.Fatalf("single Fire produced %d pushes, want exactly 1", got)
}
}
// Group A: Fire() is non-blocking even when nothing consumes the signal (the worker is
// mid-sleep in production) — the buffered-1 channel's default branch returns immediately.
func TestTrigger_FireNonBlocking(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil)
// Deliberately NOT started: the signal buffer fills after one Fire, so every
// subsequent call exercises the "worker not listening" path.
for i := 0; i < 100; i++ {
start := time.Now()
tr.Fire()
if elapsed := time.Since(start); elapsed > time.Millisecond {
t.Fatalf("Fire() call %d took %s, want < 1ms (must never block a handler)", i, elapsed)
}
}
if rec.count() != 0 {
t.Fatalf("Fire without a running worker pushed %d times, want 0", rec.count())
}
}
// Group B (Scenario B): a 10-fire burst coalesces under the hard ceiling
// (1 + ceil(burst/minInterval) = 2 for this pacing) and the LAST push happens after the
// last fire (trailing edge — the final action's state reaches the hub, never lost).
// RED-PROOF (recorded in REPORT.md): deliver every signal straight to fire (naive
// `for { <-signal; fire() }` loop) → this test fails with ~10 calls.
func TestTrigger_BurstCoalescesTrailingEdge(t *testing.T) {
rec := &fireRecorder{}
// quiet 30ms, minInterval 400ms; burst spans ~45ms → ceiling = 1 + ceil(45/400) = 2.
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
startTrigger(t, tr)
var lastFire time.Time
for i := 0; i < 10; i++ {
tr.Fire()
lastFire = time.Now()
time.Sleep(5 * time.Millisecond)
}
// Let the burst fully settle: quiet + minInterval + generous margin.
waitForCount(t, rec, 1, 2*time.Second)
time.Sleep(700 * time.Millisecond)
got := rec.count()
if got < 1 || got > 2 {
t.Fatalf("10-fire burst produced %d pushes, want 1..2 (ceiling = 1 + ceil(burst/minInterval))", got)
}
if last := rec.lastCall(); !last.After(lastFire) {
t.Fatalf("last push at %s is not after the last fire at %s — trailing edge lost (10th action's state would wait for the 15-min cycle)", last.Format(time.RFC3339Nano), lastFire.Format(time.RFC3339Nano))
}
}
// Group C (Scenario C): a fire error is isolated — the worker keeps running and a
// SUBSEQUENT fire still pushes (the failed state is reconciled by the next cycle, the
// trigger itself adds no retry).
// RED-PROOF (recorded in REPORT.md): make Run return on fire error → the second
// waitForCount here fails (no push ever comes).
func TestTrigger_FireErrorWorkerContinues(t *testing.T) {
rec := &fireRecorder{err: errors.New("hub unreachable")}
tr := newTriggerWithPacing(rec.fire, 20*time.Millisecond, 50*time.Millisecond, nil)
startTrigger(t, tr)
tr.Fire()
waitForCount(t, rec, 1, 2*time.Second) // the failing push was attempted
rec.setErr(nil) // "hub back up"
tr.Fire()
waitForCount(t, rec, 2, 2*time.Second) // the worker survived the error and pushed again
}
// §8: the worker exits promptly on context cancel, even while mid-wait (a pending fire
// may be dropped — the scheduled cycle covers it; shutdown never hangs on the trigger).
func TestTrigger_CancelDuringWaitExitsPromptly(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) // waits would block ~forever
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
tr.Run(ctx)
close(done)
}()
tr.Fire() // worker enters the hour-long quiet sleep
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not exit within 1s of context cancel")
}
if rec.count() != 0 {
t.Fatalf("cancelled mid-quiet-window but fired %d times, want 0", rec.count())
}
}
+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)