hub: HostCapabilityChecker — operator alert on agent capability-degraded (v0.19.0)

Companion to felhom-agent v0.44.0. New monitor.HostCapabilityChecker (sibling of
HostStalenessChecker) reads the capabilities snapshot from the latest host report and emits
agent_capability_degraded/recovered (operator-only, 1h cooldown) on ok<->degraded transitions
for any Critical capability. store.GetHostCapabilities (MAX(id), no migration). Goldens mirror
the new capabilities field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
2026-06-29 18:50:50 +02:00
parent c6bae9515a
commit b7b165bff5
6 changed files with 363 additions and 0 deletions
+49
View File
@@ -1624,3 +1624,52 @@ func (s *Store) GetHostStaleness() ([]HostStaleRow, error) {
}
return out, rows.Err()
}
// CapabilityStatus mirrors the agent's capability.Status wire shape (felhom-agent v0.44.0): one
// privileged `sudo -n` grant the non-root agent depends on, and whether it is currently usable.
type CapabilityStatus struct {
Name string `json:"name"`
Feature string `json:"feature"`
Critical bool `json:"critical"`
Status string `json:"status"` // "ok" | "degraded"
Reason string `json:"reason,omitempty"`
}
// HostCapabilityRow is the per-host capability snapshot the HostCapabilityChecker reads — extracted
// from the latest host-report's report_json (no dedicated column; the array rides the report body).
type HostCapabilityRow struct {
HostID string
CustomerID string
Capabilities []CapabilityStatus
}
// GetHostCapabilities returns the latest capability snapshot per host (from the most recent
// host_reports row). Hosts whose latest report carries no capabilities array (a pre-v0.44.0 agent)
// yield an empty slice — the checker treats that as "ok/unknown" and never alerts, so an old agent
// can't trip a false degraded.
func (s *Store) GetHostCapabilities() ([]HostCapabilityRow, error) {
rows, err := s.db.Query(`
SELECT hr.host_id, hr.customer_id, hr.report_json
FROM host_reports hr
JOIN (SELECT host_id, MAX(id) AS mx FROM host_reports GROUP BY host_id) latest
ON hr.id = latest.mx`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HostCapabilityRow
for rows.Next() {
var r HostCapabilityRow
var reportJSON string
if err := rows.Scan(&r.HostID, &r.CustomerID, &reportJSON); err != nil {
return nil, err
}
var body struct {
Capabilities []CapabilityStatus `json:"capabilities"`
}
_ = json.Unmarshal([]byte(reportJSON), &body) // a malformed/old body → nil caps → no alert
r.Capabilities = body.Capabilities
out = append(out, r)
}
return out, rows.Err()
}