hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)

Host page "Operator Actions" card: run off-site backup now, run a check now
(fixed job list), stop / extend (1-30 days) a deletion countdown. POST
/hosts/{id}/operator-action validates against the CLOSED list before
storing (unknown -> 400, no row), stores operator_actions(id, customer_id,
action, arg, requested_at, requested_by, done_at, outcome, message), logs
who pressed (channel + address) and bumps the box's intent. The report ACK
lists pending rows as operator_actions until the box's
operator_action_results closes them (matched on id AND reporting
customer); each closed row becomes a hub-minted operator_action event
(stored, never dispatched). Unanswered after 24 h: expired. A customer
RESET cancels pending rows. Wire gate: new root + field-by-field mirror
(controller report.OperatorAction) — needs the controller commit first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 14:38:53 +02:00
parent d604e624a3
commit 87af859fc3
14 changed files with 850 additions and 2 deletions
+16
View File
@@ -481,6 +481,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
}
}
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185): the box's results for the actions
// the ACK listed. Each closes its row — matched on the id AND this report's customer, so a result
// naming another customer's action changes nothing — and becomes a hub-minted event (stored only,
// never dispatched: an operator record, not a customer mail). Old controllers never send this.
h.ingestOperatorActionResults(payload.CustomerID, body)
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
var drPayload struct {
@@ -609,6 +615,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
resp["controller_log_requested"] = true
}
// Decision 185: the operator's pending actions for this box, listed until each result arrives.
// Read AFTER the results above were recorded, so an action answered in this report is not
// listed back in the same reply. Omitted when none (an old controller ignores the field).
if acts, err := h.store.PendingOperatorActions(payload.CustomerID); err != nil {
h.logger.Printf("[WARN] operator actions for %s could not be read (not listed this cycle): %v", payload.CustomerID, err)
} else if len(acts) > 0 {
resp["operator_actions"] = acts
h.logger.Printf("[DEBUG] operator actions listed for %s: %d", payload.CustomerID, len(acts))
}
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
// else global default) and the latest available version. The controller compares its current
// version against the floor and auto-updates when below it (latest stays the customer's opt-in