package web import ( "context" "sort" "strings" "sync" "time" ) // R-546 — is the box READY to run the escrow ceremony? // // Measured 2026-09-16/17 (chaos night Phase 0): after a fresh bind the box has no PBS storage for ~17 // minutes. The agent's preflight is red, the ceremony refuses, and the R-543 reminder bar (v0.245.0) // was on every page urging the household into it. The escrow page itself already hid its start form // behind a red checklist — but it said nothing about WAITING, and a direct POST /api/escrow/start // (the path chaos night used) ran the ceremony and returned the agent's raw "-storage" stderr. // // THE DEFINITION IS THE AGENT'S OWN `ok`: every blocking preflight item (pbs_storage_id, dr_tier, // age_binary, hub_upload, sudo_grant; staged_secret is informational). Not a controller-side copy of // one of those items — the escrow.pbs_storage_id reading of R-546 was the SYMPTOM that box showed, and // a copy of one item is a second definition that drifts when the agent grows a sixth. // // Three answers, and UNKNOWN keeps today's behaviour (the bar shows): an unreachable agent must not // silence a reminder that R-543 made loud on purpose. // // Cost: the bar hangs off every page render, so the answer is cached for escrowReadyTTL, and a probe // is only ever made while the box is paused (escrowPaused gates it) — a finished box never asks. const ( escrowReadyTTL = 60 * time.Second escrowReadyTimeout = 3 * time.Second ) type escrowReadinessCache struct { mu sync.Mutex ready bool known bool checkedAt time.Time lastState string // for transition-only INFO logging } // escrowReadiness returns (ready, known). fresh=true skips the cache (the escrow page and the start // API, where one request justifies one probe); fresh=false is the bar's cached read. func (s *Server) escrowReadiness(ctx context.Context, fresh bool) (ready, known bool) { c := &s.escrowReady c.mu.Lock() defer c.mu.Unlock() if !fresh && !c.checkedAt.IsZero() && time.Since(c.checkedAt) < escrowReadyTTL { return c.ready, c.known } pctx, cancel := context.WithTimeout(ctx, escrowReadyTimeout) defer cancel() ready, known, why := s.probeEscrowReadiness(pctx) c.ready, c.known, c.checkedAt = ready, known, time.Now() state := "unknown" if known && ready { state = "ready" } else if known { state = "not-ready" } if state != c.lastState { switch state { case "ready": s.logger.Printf("[INFO] [web] escrow readiness: READY (agent preflight ok) — the recovery-code reminder is shown") case "not-ready": s.logger.Printf("[INFO] [web] escrow readiness: NOT READY (%s) — reminder held back; the escrow page says the box is still preparing", why) default: s.logger.Printf("[INFO] [web] escrow readiness: UNKNOWN (%s) — reminder shown (fail loud)", why) } c.lastState = state } else if s.isDebug() { s.logger.Printf("[DEBUG] [web] escrow readiness: %s (%s)", state, why) } return ready, known } // probeEscrowReadiness asks the agent. Never an error to the caller: failure is "unknown". func (s *Server) probeEscrowReadiness(ctx context.Context) (ready, known bool, why string) { agent, err := s.escrowAgentConn() if err != nil { return false, false, "agent unavailable: " + err.Error() } pf, err := agent.EscrowPreflight(ctx) if err != nil { return false, false, "preflight failed: " + err.Error() } if pf.OK { return true, true, "preflight ok" } var failing []string for _, it := range pf.Items { if !it.OK && it.ID != "staged_secret" { failing = append(failing, it.ID) } } sort.Strings(failing) return false, true, "failing: " + strings.Join(failing, ", ") } // escrowNotReadyMessage is the one Hungarian sentence for "wait" — the page card and the API refusal // say the same thing. const escrowNotReadyMessage = "A doboz még készül — a távoli mentés kulcsát pár perc múlva tudod létrehozni. Ez az oldal magától frissül."