hub v0.45.0: floor-UI separation + effective-floor source + per-box MinAgent conditional floor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 15:33:14 +02:00
parent 37e60b46d2
commit bbecf0592e
15 changed files with 730 additions and 44 deletions
+46 -2
View File
@@ -745,6 +745,48 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c
http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther)
}
// countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the
// proposed global) would exceed their reported controller version — i.e. how many boxes a proposed
// global-floor save would immediately push into an update. Boxes with a per-customer override are
// governed by that override, not the proposed global, so they are excluded from the global-save
// blast radius (the confirm dialog is about the GLOBAL knob). Reused by the confirm-count endpoint.
func (s *Server) countBoxesBelowFloor(proposedGlobal string) int {
customers, err := s.store.GetCustomers()
if err != nil {
return 0
}
configs, _ := s.store.ListCustomerConfigs()
override := make(map[string]string, len(configs))
for _, c := range configs {
if c.MinControllerVersion != "" {
override[c.CustomerID] = c.MinControllerVersion
}
}
n := 0
for _, c := range customers {
floor := proposedGlobal
if ov, ok := override[c.CustomerID]; ok {
floor = ov // an overridden box is not moved by the global knob
}
if floor != "" && c.ControllerVersion != "" && compareVersions(floor, c.ControllerVersion) > 0 {
n++
}
}
return n
}
// handleGlobalFloorImpact answers the confirm dialog's "how many boxes are below <v>?" probe
// (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0.
func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) {
v, ok := normalizeFloorInput(r.URL.Query().Get("v"))
count := 0
if ok && v != "" {
count = s.countBoxesBelowFloor(v)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count})
}
// handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed
// updates). Empty clears the hub_settings override, falling back to the config/env default.
func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
@@ -796,7 +838,8 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
}
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
if !okAV || !okGV {
minAgent, okMA := normalizeFloorInput(r.FormValue("min_agent")) // Part D: empty = uncoupled release
if !okAV || !okGV || !okMA {
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
return
}
@@ -811,12 +854,13 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
AgentSHA256: agentSHA,
GoldenVersion: goldenVer,
GoldenSHA256: goldenSHA,
MinAgent: minAgent,
}); err != nil {
s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q", agentVer, goldenVer, minAgent)
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
}