package api import ( "encoding/json" "net/http" "reflect" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) // AgentResourceFindingSnapshot is the agent-consumable projection of a // finding. Intentionally narrower than the full Finding shape — agents // reasoning about a resource need the situation, not every internal // flag. Carries the seven-question schema essentials (title, severity, // impact, recommendation, confidence, regression count, previous fix) // plus identity. Agents that need deeper detail can fetch the full // finding via the existing finding endpoints. type AgentResourceFindingSnapshot struct { ID string `json:"id"` Title string `json:"title"` Severity string `json:"severity"` Category string `json:"category,omitempty"` Description string `json:"description,omitempty"` Impact string `json:"impact,omitempty"` Recommendation string `json:"recommendation,omitempty"` Confidence string `json:"confidence,omitempty"` RegressionCount int `json:"regressionCount"` PreviousResolvedFixSummary string `json:"previousResolvedFixSummary,omitempty"` DetectedAt string `json:"detectedAt,omitempty"` LastSeenAt string `json:"lastSeenAt,omitempty"` } // AgentResourceActionVerification is the agent-stable projection of // the post-execution read-after-write probe the broker runs after // every successful dispatch. Carries enough for an agent to close // the certainty loop ("did it actually work?") without fetching // /api/actions/{id} for full output. `Ran` is the existence bit: // false means no verification was attempted (the action class has // no derivable check, or the dispatch failed before verification // could run). Output is intentionally omitted from the projection // — verification stdout can be large; agents that need it follow // up via the audit endpoint. Verification command follows the same // redaction rule as action commands. type AgentResourceActionVerification struct { Ran bool `json:"ran"` Success bool `json:"success"` Command string `json:"command,omitempty"` CommandRedacted bool `json:"commandRedacted,omitempty"` Note string `json:"note,omitempty"` RanAt time.Time `json:"ranAt,omitempty"` } // AgentResourceActionSummary is the agent-consumable projection of an // action audit record. Includes the refusal-reason prefix tokens // (resource_remediation_locked:, plan_drift:) verbatim so agents can // branch on them without parsing the human message. Command is present // only for session callers or API tokens with action execution scope; // monitoring-read tokens receive commandRedacted instead. type AgentResourceActionSummary struct { ID string `json:"id"` CapabilityName string `json:"capabilityName"` Command string `json:"command,omitempty"` CommandRedacted bool `json:"commandRedacted,omitempty"` State string `json:"state"` Success bool `json:"success"` ErrorMessage string `json:"errorMessage,omitempty"` Verification *AgentResourceActionVerification `json:"verification,omitempty"` RequestedBy string `json:"requestedBy,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // AgentResourceApprovalSummary is the agent-consumable projection of // a pending approval request scoped to a specific resource. Carries // just enough for an agent that holds approval authority to decide // whether to act, fetch full context via /api/approvals/{id}, or // escalate. Command is present only for session callers or API tokens // with action execution scope; monitoring-read tokens receive // commandRedacted instead. Mirrors the shape of approval.pending SSE // events so "what's pending right now" (this bundle) and "what just // became pending" (the SSE stream) speak the same vocabulary. type AgentResourceApprovalSummary struct { ID string `json:"id"` Command string `json:"command,omitempty"` CommandRedacted bool `json:"commandRedacted,omitempty"` RiskLevel string `json:"riskLevel"` RequestedBy string `json:"requestedBy,omitempty"` RequestedAt time.Time `json:"requestedAt"` ExpiresAt time.Time `json:"expiresAt"` } // AgentFleetFindingCounts is the per-severity finding rollup carried // on each fleet-context entry. Keys are the canonical severity // strings agents already branch on elsewhere (`critical`, `warning`, // `info`). `total` is the sum so agents can sort/triage without // summing client-side. type AgentFleetFindingCounts struct { Total int `json:"total"` Critical int `json:"critical"` Warning int `json:"warning"` Info int `json:"info"` } // AgentFleetResourceSummary is the per-resource thin rollup an agent // uses for triage. Carries identity, the operator-intent flags that // gate auto-action (intentionallyOffline, neverAutoRemediate, // maintenanceWindowActive), per-severity finding counts, and the // pending-approval count. Designed to be light enough for a one-read // fleet sweep — agents that want depth on a flagged resource follow // up via /api/agent/resource-context/{id}. type AgentFleetResourceSummary struct { CanonicalID string `json:"canonicalId"` ResourceType string `json:"resourceType"` ResourceName string `json:"resourceName"` Technology string `json:"technology,omitempty"` IntentionallyOffline bool `json:"intentionallyOffline"` NeverAutoRemediate bool `json:"neverAutoRemediate"` MaintenanceWindowActive bool `json:"maintenanceWindowActive"` Findings AgentFleetFindingCounts `json:"findings"` PendingApprovalCount int `json:"pendingApprovalCount"` } // AgentFleetContext is the bundled, agent-consumable triage view // across every resource visible to the org. One read returns thin // per-resource summaries — enough for an agent to pick a focus // before drilling into the per-resource bundle. The list is always // an array (never null) so agents iterate without nil-checking. type AgentFleetContext struct { Resources []AgentFleetResourceSummary `json:"resources"` GeneratedAt time.Time `json:"generatedAt"` } // AgentResourceOperatorState mirrors the canonical // `unified.ResourceOperatorState` but with the same JSON shape the // `/api/resources/{id}/operator-state` endpoint already returns. Kept // as a separate type so the bundle's JSON can be agent-stable even if // the underlying store type's JSON tags shift. type AgentResourceOperatorState struct { IntentionallyOffline bool `json:"intentionallyOffline"` NeverAutoRemediate bool `json:"neverAutoRemediate"` MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"` MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"` MaintenanceReason string `json:"maintenanceReason,omitempty"` Criticality string `json:"criticality,omitempty"` Note string `json:"note,omitempty"` SetAt time.Time `json:"setAt"` SetBy string `json:"setBy,omitempty"` // MaintenanceWindowActive reports whether a window covers `now` — // computed once on the server so agents don't need to re-evaluate // the start/end timestamps client-side. MaintenanceWindowActive bool `json:"maintenanceWindowActive"` } // AgentResourceContext is the bundled, agent-consumable situated // picture of a resource. One read returns: // - core identity (canonical id, type, name) // - operator-set state, with computed maintenance-window-active flag // - active findings (lightweight snapshot of the seven-question schema) // - recent action attempts (including refused dispatches with their // stable error tokens) // // The endpoint trades coverage for shape: an agent gets enough to // reason about the next move without chaining several calls. Deeper // detail (full finding records, full audit history) remains available // via the existing per-finding / per-audit endpoints. type AgentResourceContext struct { CanonicalID string `json:"canonicalId"` ResourceType string `json:"resourceType"` ResourceName string `json:"resourceName"` Technology string `json:"technology,omitempty"` OperatorState *AgentResourceOperatorState `json:"operatorState,omitempty"` ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"` PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"` RecentActions []AgentResourceActionSummary `json:"recentActions"` GeneratedAt time.Time `json:"generatedAt"` } // AgentFindingsProvider returns the active findings for a resource as // agent-stable snapshots. The implementation lives outside this // package (the patrol service holds the canonical findings store); // this interface keeps the api layer free of an `internal/ai` import. type AgentFindingsProvider interface { ActiveFindingsForResource(resourceID string) []AgentResourceFindingSnapshot } // agentFindingsProviderFunc is a function adapter so wire-up code can // pass a closure without declaring a struct. type agentFindingsProviderFunc func(resourceID string) []AgentResourceFindingSnapshot // ActiveFindingsForResource implements AgentFindingsProvider. func (f agentFindingsProviderFunc) ActiveFindingsForResource(resourceID string) []AgentResourceFindingSnapshot { if f == nil { return nil } return f(resourceID) } // AgentApprovalsProvider returns still-pending approvals as // agent-stable projections. Per-resource context needs full // summaries; fleet context needs only a resource-keyed count map so // it can aggregate approval pressure without scanning the global // approval list once per resource. The implementation lives outside // this package (the approval store is owned by the AIHandler); this // interface keeps the bundle handler decoupled from the global // approval store and lets tests pass a fake. type AgentApprovalsProvider interface { PendingApprovalsForResource(resourceID, orgID string) []AgentResourceApprovalSummary PendingApprovalCountsByResource(orgID string) map[string]int } // agentApprovalStoreProvider adapts the process-global approval // store into the agent context provider contract. It resolves the // store at request time so multi-tenant rebuilds that install a new // global store via approval.SetStore are honored without re-wiring // the router. type agentApprovalStoreProvider struct{} func (agentApprovalStoreProvider) PendingApprovalsForResource(resourceID, orgID string) []AgentResourceApprovalSummary { return pendingApprovalsForResourceFromStore(approval.GetStore(), resourceID, orgID) } func (agentApprovalStoreProvider) PendingApprovalCountsByResource(orgID string) map[string]int { return pendingApprovalCountsByResourceFromStore(approval.GetStore(), orgID) } // AgentContextHandler owns the agent-paradigm bundled context // endpoint. Kept as a separate type from `ResourceHandlers` so the // agent surface evolves independently of the resource CRUD surface // and the resource handler stays focused on its existing concerns. // The handler reuses the resource handler for registry + store // access (those are the canonical accessors); the // agent-findings adapter is held here. type AgentContextHandler struct { resources *ResourceHandlers findingsProvider AgentFindingsProvider approvalsProvider AgentApprovalsProvider } // NewAgentContextHandler creates a new agent context handler. The // resource handler is required (registry + store come from it); the // findings provider may be set later via SetFindingsProvider. func NewAgentContextHandler(resources *ResourceHandlers) *AgentContextHandler { return &AgentContextHandler{resources: resources} } // SetFindingsProvider wires the active-findings adapter. Pass nil to // disable the active-findings section of the bundle. func (h *AgentContextHandler) SetFindingsProvider(p AgentFindingsProvider) { h.findingsProvider = p } // SetApprovalsProvider wires the pending-approvals adapter. Pass nil // to disable the pending-approvals section of the bundle (callers // will always see `pendingApprovals: []` rather than a missing field // so agents can iterate without nil-checking). func (h *AgentContextHandler) SetApprovalsProvider(p AgentApprovalsProvider) { h.approvalsProvider = p } // HandleResourceContext serves // `GET /api/agent/resource-context/{id}` — the agent-consumable // situated picture of a resource. Bundle is computed at request time // (no caching) so agents always see live state. Limits on the // recent-actions slice keep the payload bounded; deeper history is // available via the existing per-resource action audit endpoints. func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if h.resources == nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } resourceID := extractAgentResourceContextID(r.URL.Path) if resourceID == "" { http.Error(w, "Resource ID required", http.StatusBadRequest) return } orgID := GetOrgID(r.Context()) registry, err := h.resources.buildRegistry(orgID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } resource, ok := registry.Get(resourceID) if !ok { writeJSONError(w, http.StatusNotFound, "resource_not_found", "No resource is registered with this canonical id.") return } store, err := h.resources.getStore(orgID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } bundle := AgentResourceContext{ CanonicalID: resourceID, ResourceType: string(resource.Type), ResourceName: resource.Name, Technology: resource.Technology, ActiveFindings: []AgentResourceFindingSnapshot{}, PendingApprovals: []AgentResourceApprovalSummary{}, RecentActions: []AgentResourceActionSummary{}, GeneratedAt: time.Now().UTC(), } // Operator-set state — single point lookup. if state, found, opErr := store.GetResourceOperatorState(resourceID); opErr == nil && found { projected := projectAgentResourceOperatorState(state, bundle.GeneratedAt) bundle.OperatorState = &projected } // Operator-state lookup errors are logged-and-ignored: the bundle // is still useful without it, and an agent can branch on the // `operatorState` field being absent. // Active findings — in-memory lookup via the patrol findings store // adapter wired at startup. if h.findingsProvider != nil { bundle.ActiveFindings = h.findingsProvider.ActiveFindingsForResource(resourceID) if bundle.ActiveFindings == nil { bundle.ActiveFindings = []AgentResourceFindingSnapshot{} } } // Pending approvals — in-memory filter against the approval // store, scoped to this org. Same pattern as findings: the // provider adapter keeps the api package free of approval-store // internals at the bundle layer; the wire-up step in router.go // does the global lookup and resource-id filter. if h.approvalsProvider != nil { bundle.PendingApprovals = h.approvalsProvider.PendingApprovalsForResource(resourceID, orgID) if bundle.PendingApprovals == nil { bundle.PendingApprovals = []AgentResourceApprovalSummary{} } } // Recent action audits — limit to 10 by default; agents that need // deeper history can call the existing per-resource action audit // endpoint. Filter window of "since one week ago" mirrors the // frontend-side ResourceActionHistory default. since := bundle.GeneratedAt.Add(-7 * 24 * time.Hour) if audits, auditErr := store.GetActionAudits(resourceID, since, 10); auditErr == nil { bundle.RecentActions = projectAgentResourceActions(audits) if bundle.RecentActions == nil { bundle.RecentActions = []AgentResourceActionSummary{} } } redactAgentResourceContextCommandsForRequest(&bundle, r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(bundle) } // extractAgentResourceContextID parses the canonical resource ID out of // the URL path. Mirrors the trim/canonicalize pattern used elsewhere // for nested resource routes. func extractAgentResourceContextID(path string) string { trimmed := strings.TrimPrefix(path, "/api/agent/resource-context/") trimmed = strings.TrimSuffix(trimmed, "/") return unified.CanonicalResourceID(trimmed) } // HandleFleetContext serves // `GET /api/agent/fleet-context` — the agent-consumable triage view // across every resource visible to the org. Each entry is a thin // rollup (identity + operator flags + per-severity finding counts + // pending-approval count) so a single read tells an agent where to // focus. Agents that want depth on a flagged resource follow up via // /api/agent/resource-context/{id}. // // Computed at request time (no caching); the registry walk is the // dominant cost. Per-resource costs are bounded: one operator-state // SQLite point lookup, one in-memory findings lookup via the // findings store's per-resource index, and a single global // pending-approvals scan that's grouped by canonical resource id // before the registry walk so the fleet sweep costs O(N) registry // reads, not O(N*M) approval scans. func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if h.resources == nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } orgID := GetOrgID(r.Context()) registry, err := h.resources.buildRegistry(orgID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } store, err := h.resources.getStore(orgID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } now := time.Now().UTC() resources := registry.List() out := AgentFleetContext{ Resources: make([]AgentFleetResourceSummary, 0, len(resources)), GeneratedAt: now, } // Pre-compute per-resource pending-approval counts in a single // pass over the bounded global list, indexed by canonical // resource id. Beats N independent provider calls because the // bounded approval list (MaxApprovals=100) is the same regardless // of fleet size — one scan suffices. pendingByResource := map[string]int{} if h.approvalsProvider != nil { counts := h.approvalsProvider.PendingApprovalCountsByResource(orgID) if counts != nil { pendingByResource = counts } } for _, resource := range resources { canonical := unified.CanonicalResourceID(resource.ID) summary := AgentFleetResourceSummary{ CanonicalID: canonical, ResourceType: string(resource.Type), ResourceName: resource.Name, Technology: resource.Technology, } if state, found, opErr := store.GetResourceOperatorState(canonical); opErr == nil && found { summary.IntentionallyOffline = state.IntentionallyOffline summary.NeverAutoRemediate = state.NeverAutoRemediate summary.MaintenanceWindowActive = state.IsInMaintenanceAt(now) } if h.findingsProvider != nil { findings := h.findingsProvider.ActiveFindingsForResource(canonical) summary.Findings = countFleetFindingsBySeverity(findings) } if count, ok := pendingByResource[canonical]; ok { summary.PendingApprovalCount = count } out.Resources = append(out.Resources, summary) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) } // countFleetFindingsBySeverity tallies the per-severity counts an // agent uses to triage. Severity strings outside the canonical set // (`critical`, `warning`, `info`) are counted in `Total` but not in // any bucket — defensive against ad-hoc severity values, but the // schema discourages them. func countFleetFindingsBySeverity(findings []AgentResourceFindingSnapshot) AgentFleetFindingCounts { counts := AgentFleetFindingCounts{} for _, f := range findings { counts.Total++ switch strings.ToLower(strings.TrimSpace(f.Severity)) { case "critical": counts.Critical++ case "warning": counts.Warning++ case "info": counts.Info++ } } return counts } // projectAgentResourceOperatorState converts the canonical store // shape into the agent-stable wire shape, computing the // maintenance-window-active flag once at the server boundary. func projectAgentResourceOperatorState( state unified.ResourceOperatorState, now time.Time, ) AgentResourceOperatorState { return AgentResourceOperatorState{ IntentionallyOffline: state.IntentionallyOffline, NeverAutoRemediate: state.NeverAutoRemediate, MaintenanceStartAt: state.MaintenanceStartAt, MaintenanceEndAt: state.MaintenanceEndAt, MaintenanceReason: state.MaintenanceReason, Criticality: string(state.Criticality), Note: state.Note, SetAt: state.SetAt, SetBy: state.SetBy, MaintenanceWindowActive: state.IsInMaintenanceAt(now), } } func projectAgentResourceActions( audits []unified.ActionAuditRecord, ) []AgentResourceActionSummary { if len(audits) == 0 { return []AgentResourceActionSummary{} } out := make([]AgentResourceActionSummary, 0, len(audits)) for _, audit := range audits { summary := AgentResourceActionSummary{ ID: audit.ID, CapabilityName: audit.Request.CapabilityName, State: string(audit.State), RequestedBy: audit.Request.RequestedBy, CreatedAt: audit.CreatedAt, UpdatedAt: audit.UpdatedAt, } if cmd, ok := audit.Request.Params["command"].(string); ok { summary.Command = cmd } if audit.Result != nil { summary.Success = audit.Result.Success summary.ErrorMessage = audit.Result.ErrorMessage if v := projectAgentResourceVerification(audit.Result.Verification); v != nil { summary.Verification = v } } out = append(out, summary) } return out } // pendingApprovalsForResourceFromStore is the named, testable form // of the AgentApprovalsProvider closure the router wires at // startup. Given an approval store, a canonical resource id, and // an org id, it returns every still-pending approval that // (a) belongs to the org via approval.BelongsToOrg, and // (b) targets exactly the requested resource via // // ApprovalRequest.CanonicalResourceID. // // Both filters apply: an approval that matches one but not the // other is excluded so cross-org and cross-resource leaks are // impossible at the substrate boundary. Returns nil for an empty // store, an empty resource id, or no matches; an empty non-nil // slice would imply "no matches" too but the bundle handler // normalizes nil to []AgentResourceApprovalSummary{} so the wire // shape is always an array. func pendingApprovalsForResourceFromStore(store approvalsPendingProvider, resourceID, orgID string) []AgentResourceApprovalSummary { if isNilApprovalsPendingProvider(store) || strings.TrimSpace(resourceID) == "" { return nil } pending := store.GetPendingApprovals() if len(pending) == 0 { return nil } out := make([]AgentResourceApprovalSummary, 0, len(pending)) for _, req := range pending { if req == nil { continue } if !approval.BelongsToOrg(req, orgID) { continue } if req.CanonicalResourceID() != resourceID { continue } out = append(out, AgentResourceApprovalSummary{ ID: req.ID, Command: req.Command, RiskLevel: string(req.RiskLevel), RequestedBy: req.RequestedBy, RequestedAt: req.RequestedAt, ExpiresAt: req.ExpiresAt, }) } return out } // pendingApprovalCountsByResourceFromStore is the fleet-context // companion to pendingApprovalsForResourceFromStore. It scans the // bounded pending-approval list once, applies the same org isolation // predicate, and groups counts by canonical resource id so the fleet // handler does not perform one store scan per resource. func pendingApprovalCountsByResourceFromStore(store approvalsPendingProvider, orgID string) map[string]int { if isNilApprovalsPendingProvider(store) { return nil } pending := store.GetPendingApprovals() if len(pending) == 0 { return nil } counts := map[string]int{} for _, req := range pending { if req == nil { continue } if !approval.BelongsToOrg(req, orgID) { continue } resourceID := req.CanonicalResourceID() if resourceID == "" { continue } counts[resourceID]++ } if len(counts) == 0 { return nil } return counts } // approvalsPendingProvider is the minimal accessor surface the // pending-approvals bundle filter depends on. The production // implementation is *approval.Store; the interface keeps the // function unit-testable without bringing the whole store up. type approvalsPendingProvider interface { GetPendingApprovals() []*approval.ApprovalRequest } func isNilApprovalsPendingProvider(store approvalsPendingProvider) bool { if store == nil { return true } v := reflect.ValueOf(store) return v.Kind() == reflect.Ptr && v.IsNil() } // projectAgentResourceVerification converts the canonical // ActionVerificationResult into the agent-stable projection that // both the resource-context bundle and the action.completed SSE // payload carry. Returns nil when the source is nil so callers can // branch on field presence — absent means "no verification result // recorded" (e.g. refused-before-dispatch failures), distinct from // "verification was skipped" which surfaces as Ran=false. func projectAgentResourceVerification(v *unified.ActionVerificationResult) *AgentResourceActionVerification { if v == nil { return nil } return &AgentResourceActionVerification{ Ran: v.Ran, Success: v.Success, Command: v.Command, Note: v.Note, RanAt: v.RanAt, } }