Pulse/internal/api/agent_resource_context.go
2026-07-11 21:27:00 +01:00

1488 lines
62 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"reflect"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcontext"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
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"`
ActionResultV2 *unified.ActionResultV2 `json:"actionResultV2,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"`
}
type AgentOperationsLoopStepStatus string
const (
agentOperationsLoopActionWindow = 30 * 24 * time.Hour
AgentOperationsLoopStepComplete AgentOperationsLoopStepStatus = "complete"
AgentOperationsLoopStepCurrent AgentOperationsLoopStepStatus = "current"
AgentOperationsLoopStepPending AgentOperationsLoopStepStatus = "pending"
)
// AgentOperationsLoopStep is a content-safe step rollup for the Patrol
// control loop: Patrol issue evidence, Assistant/context explanation,
// governed approve/reject decision, and verified outcome. Counts are aggregate
// only and never carry finding ids, action ids, resource names, commands,
// prompts, or output. External-agent readiness is a separate capability signal,
// not a Patrol operator step.
type AgentOperationsLoopStep struct {
ID string `json:"id"`
Label string `json:"label"`
Status AgentOperationsLoopStepStatus `json:"status"`
Count int `json:"count,omitempty"`
}
// AgentOperationsLoopStatus is the agent-consumable status projection for the
// same Pulse Intelligence loop shown in the native Patrol control journey. It
// exists so external MCP agents can orient on the operator stage without
// reverse-engineering it from fleet context, finding lists, approvals, and
// action audits. The payload is intentionally count-only. MCP readiness stays
// separate from steps so optional external access does not look like a required
// user journey stage.
type AgentOperationsLoopStatus struct {
NextAction string `json:"nextAction"`
ProgressLabel string `json:"progressLabel"`
Steps []AgentOperationsLoopStep `json:"steps"`
PatrolEvidenceCount int `json:"patrolEvidenceCount"`
PatrolIssueEvidenceCount int `json:"patrolIssueEvidenceCount"`
ActiveFindingCount int `json:"activeFindingCount"`
PendingApprovalCount int `json:"pendingApprovalCount"`
GovernedActionCount int `json:"governedActionCount"`
ApprovedDecisionCount int `json:"approvedDecisionCount"`
RejectedDecisionCount int `json:"rejectedDecisionCount"`
VerifiedOutcomeCount int `json:"verifiedOutcomeCount"`
OperationsLoopStarterCount int `json:"operationsLoopStarterCount"`
AssistantOperationsLoopStarterCount int `json:"assistantOperationsLoopStarterCount"`
PatrolOperationsLoopStarterCount int `json:"patrolOperationsLoopStarterCount"`
PatrolControlLoopStarterCount int `json:"patrolControlOperationsLoopStarterCount"`
PatrolControlCompletedLoopCount int `json:"patrolControlCompletedOperationsLoopCount"`
PatrolControlResolvedLoopCount int `json:"patrolControlResolvedOperationsLoopCount"`
PatrolControlValueState string `json:"patrolControlValueState"`
PatrolAutonomyLoopStarterCount int `json:"patrolAutonomyOperationsLoopStarterCount"`
PatrolAutonomyCompletedLoopCount int `json:"patrolAutonomyCompletedOperationsLoopCount"`
PatrolAutonomyResolvedLoopCount int `json:"patrolAutonomyResolvedOperationsLoopCount"`
PatrolAutonomyValueState string `json:"patrolAutonomyValueState"`
ProActivationLoopStarterCount int `json:"proActivationOperationsLoopStarterCount"`
ProActivationCompletedLoopCount int `json:"proActivationCompletedOperationsLoopCount"`
ProActivationResolvedLoopCount int `json:"proActivationResolvedOperationsLoopCount"`
ProActivationValueProofState string `json:"proActivationValueProofState"`
MCPOperationsLoopStarterCount int `json:"mcpOperationsLoopStarterCount"`
ExternalAgentReady bool `json:"externalAgentReady"`
WindowStart time.Time `json:"windowStart"`
GeneratedAt time.Time `json:"generatedAt"`
assistantContextCount int
externalAgentCollaborationCount int
}
// 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"`
}
// AgentResourceContextFact is a bounded, typed fact in the richer
// agent-consumable context pack. Values come from Pulse-owned runtime
// state only; raw command output, config files, environment variables,
// and secret-bearing metadata do not belong here.
type AgentResourceContextFact = agentcontext.Fact
// AgentResourceContextRedaction records why a context section withheld
// a field. It gives agents and UI inspectors a visible safety boundary
// without leaking the underlying value.
type AgentResourceContextRedaction = agentcontext.Redaction
// AgentResourceContextSection groups related facts with one
// provenance/freshness envelope. Additive sections make the context
// pack useful to Assistant and external MCP clients without breaking
// older clients that parse the original top-level fields.
type AgentResourceContextSection = agentcontext.Section
// AgentResourceDiscoveryReadiness is the agent-stable projection of
// service-discovery freshness already carried on resource API payloads.
type AgentResourceDiscoveryReadiness = unified.ResourceDiscoveryReadiness
// 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)
// - additive context sections with provenance, freshness, and
// redaction metadata for richer Assistant and external-agent
// grounding
//
// 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"`
DiscoveryReadiness *AgentResourceDiscoveryReadiness `json:"discoveryReadiness,omitempty"`
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
RecentActions []AgentResourceActionSummary `json:"recentActions"`
ContextSections []AgentResourceContextSection `json:"contextSections"`
GeneratedAt time.Time `json:"generatedAt"`
}
// AgentResourceCapabilities is the structured capability surface for a single
// resource: the names and parameter schemas an agent needs to populate
// plan_action.capabilityName and params. Mirrors Resource.Capabilities but is
// projected at request time so the wire shape stays stable. The internal
// execution handler is excluded via its `json:"-"` tag.
type AgentResourceCapabilities struct {
ResourceID string `json:"resourceId"`
Capabilities []unified.ResourceCapability `json:"capabilities"`
GeneratedAt time.Time `json:"generatedAt"`
}
// AgentFindingsProvider returns active findings as agent-stable snapshots and
// count-only aggregate evidence. 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
}
type AgentAggregateFindingsProvider interface {
ActiveFindingCount() int
}
type agentFindingsProvider struct {
activeForResource func(resourceID string) []AgentResourceFindingSnapshot
activeCount func() int
}
func (p agentFindingsProvider) ActiveFindingsForResource(resourceID string) []AgentResourceFindingSnapshot {
if p.activeForResource == nil {
return nil
}
return p.activeForResource(resourceID)
}
func (p agentFindingsProvider) ActiveFindingCount() int {
if p.activeCount == nil {
return 0
}
return p.activeCount()
}
// 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
workflowPromptActivityProvider AgentWorkflowPromptActivityProvider
aiUsageProvider AgentAIUsageProvider
externalAgentActivityProvider AgentExternalAgentActivityProvider
externalAgentReadinessProvider AgentExternalAgentReadinessProvider
}
// AgentWorkflowPromptActivityProvider loads content-free workflow prompt
// starter activity for the current request context. Implementations are
// responsible for tenant resolution so the agent status projection can stay
// scoped to the authenticated org.
type AgentWorkflowPromptActivityProvider interface {
WorkflowPromptActivityHistory(ctx context.Context) (*config.WorkflowPromptActivityHistoryData, error)
}
type agentWorkflowPromptActivityProviderFunc func(context.Context) (*config.WorkflowPromptActivityHistoryData, error)
func (fn agentWorkflowPromptActivityProviderFunc) WorkflowPromptActivityHistory(ctx context.Context) (*config.WorkflowPromptActivityHistoryData, error) {
return fn(ctx)
}
// AgentAIUsageProvider loads content-free AI usage evidence for the operations
// loop status projection.
type AgentAIUsageProvider interface {
AIUsageHistory(ctx context.Context) (*config.AIUsageHistoryData, error)
}
type agentAIUsageProviderFunc func(context.Context) (*config.AIUsageHistoryData, error)
func (fn agentAIUsageProviderFunc) AIUsageHistory(ctx context.Context) (*config.AIUsageHistoryData, error) {
return fn(ctx)
}
// AgentExternalAgentActivityProvider loads content-free external-agent
// capability activity for the operations loop status projection.
type AgentExternalAgentActivityProvider interface {
ExternalAgentActivityHistory(ctx context.Context) (*config.ExternalAgentActivityHistoryData, error)
}
type agentExternalAgentActivityProviderFunc func(context.Context) (*config.ExternalAgentActivityHistoryData, error)
func (fn agentExternalAgentActivityProviderFunc) ExternalAgentActivityHistory(ctx context.Context) (*config.ExternalAgentActivityHistoryData, error) {
return fn(ctx)
}
// AgentExternalAgentReadinessProvider reports whether the current org has one
// non-expired token that can use the full Pulse MCP operations-loop tool set.
// The status payload exposes only the boolean, never token identity or counts.
type AgentExternalAgentReadinessProvider interface {
ExternalAgentReady(ctx context.Context, manifest agentcapabilities.Manifest, now time.Time) bool
}
type agentExternalAgentReadinessProviderFunc func(context.Context, agentcapabilities.Manifest, time.Time) bool
func (fn agentExternalAgentReadinessProviderFunc) ExternalAgentReady(ctx context.Context, manifest agentcapabilities.Manifest, now time.Time) bool {
return fn(ctx, manifest, now)
}
// 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
}
// SetWorkflowPromptActivityProvider wires the content-free workflow starter
// activity source used by the operations-loop status projection. Pass nil to
// omit starter counts without affecting Patrol, governance, or verification
// evidence.
func (h *AgentContextHandler) SetWorkflowPromptActivityProvider(p AgentWorkflowPromptActivityProvider) {
h.workflowPromptActivityProvider = p
}
// SetAIUsageProvider wires content-free Assistant usage evidence into the
// operations-loop status projection.
func (h *AgentContextHandler) SetAIUsageProvider(p AgentAIUsageProvider) {
h.aiUsageProvider = p
}
// SetExternalAgentActivityProvider wires content-free external-agent activity
// evidence into the operations-loop status projection.
func (h *AgentContextHandler) SetExternalAgentActivityProvider(p AgentExternalAgentActivityProvider) {
h.externalAgentActivityProvider = p
}
// SetExternalAgentReadinessProvider wires the token-backed external-agent
// setup signal used by the operations-loop status projection.
func (h *AgentContextHandler) SetExternalAgentReadinessProvider(p AgentExternalAgentReadinessProvider) {
h.externalAgentReadinessProvider = 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, resourceID, ok := presentationResourceByReference(registry, resourceID)
if !ok {
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound,
"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
}
generatedAt := time.Now().UTC()
resourceCopy := *resource
attachDiscoveryTarget(&resourceCopy)
h.resources.attachDiscoveryReadiness(&resourceCopy, generatedAt)
bundle := AgentResourceContext{
CanonicalID: resourceID,
ResourceType: string(resourceCopy.Type),
ResourceName: resourceCopy.Name,
Technology: resourceCopy.Technology,
DiscoveryReadiness: resourceCopy.DiscoveryReadiness,
ActiveFindings: []AgentResourceFindingSnapshot{},
PendingApprovals: []AgentResourceApprovalSummary{},
RecentActions: []AgentResourceActionSummary{},
ContextSections: []AgentResourceContextSection{},
GeneratedAt: generatedAt,
}
// 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{}
}
}
bundle.ContextSections = buildAgentResourceContextSections(resourceCopy, store, bundle)
redactAgentResourceContextCommandsForRequest(&bundle, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bundle)
}
func buildAgentResourceContextSections(resource unified.Resource, store unified.ResourceStore, bundle AgentResourceContext) []AgentResourceContextSection {
return agentcontext.BuildResourceContextSections(resource, store, agentcontext.BuildOptions{
GeneratedAt: bundle.GeneratedAt,
OperatorState: agentResourceOperatorStateForContext(bundle.OperatorState),
ActiveFindingCount: len(bundle.ActiveFindings),
PendingApprovalCount: len(bundle.PendingApprovals),
RecentActionCount: len(bundle.RecentActions),
})
}
// HandleResourceCapabilities serves
// `GET /api/agent/resource-capabilities/{id}` — the structured
// capability surface for a single resource: the names and parameter
// schemas an agent reads before calling plan_action. Companion to the
// resource-context bundle, which renders capabilities as count-limited
// prose facts (omitting parameter schemas). A resource with no
// advertised capabilities returns 200 with an empty capabilities array,
// which is the signal an agent should not attempt plan_action against it.
//
// Computed at request time (no caching). The capability list is the
// in-memory Resource.Capabilities slice assembled by the registry, so
// this endpoint only reads the canonical source of truth that
// plan_action validates against.
func (h *AgentContextHandler) HandleResourceCapabilities(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 := extractAgentResourceCapabilitiesID(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, resourceID, ok := presentationResourceByReference(registry, resourceID)
if !ok {
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound,
"No resource is registered with this canonical id.")
return
}
// Capabilities default to a non-nil empty array so the wire shape is
// always a JSON array, never null. A resource advertising nothing is a
// valid, actionable answer (the signal to skip plan_action).
capabilities := []unified.ResourceCapability{}
if len(resource.Capabilities) > 0 {
capabilities = append(capabilities, resource.Capabilities...)
}
bundle := AgentResourceCapabilities{
ResourceID: resourceID,
Capabilities: capabilities,
GeneratedAt: time.Now().UTC(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bundle)
}
func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *agentcontext.OperatorState {
if state == nil {
return nil
}
return &agentcontext.OperatorState{
IntentionallyOffline: state.IntentionallyOffline,
NeverAutoRemediate: state.NeverAutoRemediate,
MaintenanceWindowActive: state.MaintenanceWindowActive,
MaintenanceStartAt: state.MaintenanceStartAt,
MaintenanceEndAt: state.MaintenanceEndAt,
Criticality: state.Criticality,
NotePresent: strings.TrimSpace(state.Note) != "",
SetAt: state.SetAt,
}
}
// 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)
}
// extractAgentResourceCapabilitiesID parses the canonical resource ID out
// of the resource-capabilities URL path. Same trim/canonicalize pattern as
// extractAgentResourceContextID against the capabilities route prefix.
func extractAgentResourceCapabilitiesID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/agent/resource-capabilities/")
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
}
}
// Optional additive filters. All empty/unset = current behavior (return
// the full fleet). Unknown values match nothing and return an empty
// resources array, which is a valid triage answer, not an error: an agent
// asking for technology=docker on a fleet with none should see [], not a
// 400. String filters are case-insensitive and trimmed.
filter := parseFleetContextFilter(r)
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
}
if !fleetContextFilterMatches(filter, summary) {
continue
}
out.Resources = append(out.Resources, summary)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
// fleetContextFilter captures the optional, additive query-param filters for
// GET /api/agent/fleet-context. A nil/zero field means "no filter on this
// dimension." Empty values (hasFindingsFilter set but severity empty, etc.)
// still match per their semantics below.
type fleetContextFilter struct {
// hasFindingsFilter is set when the hasFindings query param is present.
// When true, only resources with at least one active finding are kept.
// When false/absent, no finding-presence filter is applied.
hasFindingsFilter bool
hasFindings bool
// severity, when non-empty, keeps only resources with at least one finding
// at that severity (critical/warning/info). Case-insensitive, trimmed.
severity string
// technology, when non-empty, keeps only resources whose Technology matches
// (case-insensitive, trimmed). Empty technology on a resource does not match
// a non-empty filter.
technology string
// resourceType, when non-empty, keeps only resources whose Type matches
// (case-insensitive, trimmed).
resourceType string
}
// parseFleetContextFilter reads optional fleet-context filter query params.
// follows the bare r.URL.Query().Get convention used elsewhere in the api
// package. Invalid values are silently ignored (no 400) so the endpoint
// degrades gracefully to a broader result rather than erroring on a typo.
func parseFleetContextFilter(r *http.Request) fleetContextFilter {
q := r.URL.Query()
f := fleetContextFilter{}
if raw := strings.TrimSpace(q.Get("hasFindings")); raw != "" {
// Bare sentinel match, matching the codebase convention (e.g. == "1").
// Any value other than "true"/"1" is treated as absent to avoid
// erroring on an unfamiliar truthy spelling.
if raw == "true" || raw == "1" {
f.hasFindingsFilter = true
f.hasFindings = true
}
}
f.severity = strings.ToLower(strings.TrimSpace(q.Get("severity")))
f.technology = strings.ToLower(strings.TrimSpace(q.Get("technology")))
f.resourceType = strings.ToLower(strings.TrimSpace(q.Get("resourceType")))
return f
}
// fleetContextFilterMatches reports whether a resource summary passes the
// active fleet-context filter. An empty filter matches everything (the
// backward-compatible unfiltered behavior).
func fleetContextFilterMatches(f fleetContextFilter, s AgentFleetResourceSummary) bool {
if f.hasFindingsFilter && f.hasFindings && s.Findings.Total == 0 {
return false
}
if f.severity != "" {
switch f.severity {
case "critical":
if s.Findings.Critical == 0 {
return false
}
case "warning":
if s.Findings.Warning == 0 {
return false
}
case "info":
if s.Findings.Info == 0 {
return false
}
default:
// Unknown severity matches nothing — a valid empty result, not an
// error. An agent asking for severity=blocker on a fleet that
// uses critical/warning/info should see [], signaling the filter
// did not apply, rather than the whole fleet.
return false
}
}
if f.technology != "" && strings.ToLower(s.Technology) != f.technology {
return false
}
if f.resourceType != "" && strings.ToLower(s.ResourceType) != f.resourceType {
return false
}
return true
}
// Pulse Intelligence Patrol control loop. The canonical route is
// `GET /api/agent/patrol-control/status`; `GET /api/agent/operations-loop/status`
// remains as a compatibility alias. It deliberately summarizes counts and
// stage state instead of exposing finding ids, action ids, commands, prompts,
// output, or resource names.
func (h *AgentContextHandler) HandleOperationsLoopStatus(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()
windowStart := now.Add(-agentOperationsLoopActionWindow)
manifest := agentcapabilities.CanonicalManifest()
status := AgentOperationsLoopStatus{
Steps: []AgentOperationsLoopStep{},
ExternalAgentReady: h.agentOperationsLoopExternalAgentReady(r.Context(), manifest, now),
WindowStart: windowStart,
GeneratedAt: now,
}
pendingByResource := map[string]int{}
if h.approvalsProvider != nil {
if counts := h.approvalsProvider.PendingApprovalCountsByResource(orgID); counts != nil {
pendingByResource = counts
}
}
for _, resource := range registry.List() {
canonical := unified.CanonicalResourceID(resource.ID)
if h.findingsProvider != nil {
status.ActiveFindingCount += len(h.findingsProvider.ActiveFindingsForResource(canonical))
}
if pending := pendingByResource[canonical]; pending > 0 {
status.PendingApprovalCount += pending
}
}
if aggregateProvider, ok := h.findingsProvider.(AgentAggregateFindingsProvider); ok {
if aggregateActive := aggregateProvider.ActiveFindingCount(); aggregateActive > status.ActiveFindingCount {
status.ActiveFindingCount = aggregateActive
}
}
if h.workflowPromptActivityProvider != nil {
if history, err := h.workflowPromptActivityProvider.WorkflowPromptActivityHistory(r.Context()); err == nil {
starterCounts := agentOperationsLoopWorkflowStarterEvidenceCounts(history, windowStart)
status.OperationsLoopStarterCount = starterCounts.total
status.AssistantOperationsLoopStarterCount = starterCounts.assistant
status.PatrolOperationsLoopStarterCount = starterCounts.patrol
status.ProActivationLoopStarterCount = starterCounts.proActivation
status.PatrolControlLoopStarterCount = starterCounts.patrolControl
status.PatrolAutonomyLoopStarterCount = status.PatrolControlLoopStarterCount
status.MCPOperationsLoopStarterCount = starterCounts.mcp
}
}
if h.aiUsageProvider != nil {
if history, err := h.aiUsageProvider.AIUsageHistory(r.Context()); err == nil {
aiEvidence := telemetry.PulseIntelligenceAIUsageEvidenceFromHistory(history, windowStart)
status.assistantContextCount = aiEvidence.AssistantContextAICalls + aiEvidence.AssistantToolCalls
}
}
if h.externalAgentActivityProvider != nil {
if history, err := h.externalAgentActivityProvider.ExternalAgentActivityHistory(r.Context()); err == nil {
externalEvidence := telemetry.PulseIntelligenceExternalAgentEvidenceFromHistory(history, windowStart)
status.externalAgentCollaborationCount = externalEvidence.CollaborationCount()
}
}
actionCounts := agentOperationsLoopActionEvidenceCounts(store, windowStart)
status.GovernedActionCount = actionCounts.governed
status.ApprovedDecisionCount = actionCounts.approved
status.RejectedDecisionCount = actionCounts.rejected
status.VerifiedOutcomeCount = actionCounts.verified
status.PatrolEvidenceCount = status.ActiveFindingCount + status.PendingApprovalCount + actionCounts.recent
status.PatrolIssueEvidenceCount = status.ActiveFindingCount + status.PendingApprovalCount + status.GovernedActionCount + status.VerifiedOutcomeCount
patrolControlProof := agentOperationsLoopPatrolControlProof(status)
if patrolControlProof.Completed {
status.PatrolControlCompletedLoopCount = 1
}
if patrolControlProof.Resolved {
status.PatrolControlResolvedLoopCount = 1
}
status.PatrolControlValueState = patrolControlProof.ValueProofState
status.PatrolAutonomyCompletedLoopCount = status.PatrolControlCompletedLoopCount
status.PatrolAutonomyResolvedLoopCount = status.PatrolControlResolvedLoopCount
status.PatrolAutonomyValueState = status.PatrolControlValueState
status.ProActivationCompletedLoopCount = status.PatrolControlCompletedLoopCount
status.ProActivationResolvedLoopCount = status.PatrolControlResolvedLoopCount
status.ProActivationValueProofState = status.PatrolControlValueState
status.NextAction, status.ProgressLabel, status.Steps = agentOperationsLoopProgress(status)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
}
// 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
}
type agentOperationsLoopActionCounts struct {
recent int
governed int
approved int
rejected int
verified int
}
type agentOperationsLoopWorkflowStarterCounts struct {
total int
assistant int
patrol int
patrolControl int
proActivation int
mcp int
}
func agentOperationsLoopWorkflowStarterEvidenceCounts(history *config.WorkflowPromptActivityHistoryData, since time.Time) agentOperationsLoopWorkflowStarterCounts {
var counts agentOperationsLoopWorkflowStarterCounts
if history == nil {
return counts
}
for _, event := range history.Events {
if strings.TrimSpace(event.PromptName) != agentcapabilities.PulseWorkflowPromptOperationsLoop {
continue
}
if event.Timestamp.Before(since) {
continue
}
counts.total++
switch strings.TrimSpace(event.Surface) {
case config.WorkflowPromptActivitySurfacePulseAssistant:
counts.assistant++
case config.WorkflowPromptActivitySurfacePulsePatrol:
counts.patrol++
counts.patrolControl++
case config.WorkflowPromptActivitySurfacePatrolControl:
counts.patrolControl++
case config.WorkflowPromptActivitySurfacePatrolAutonomy:
counts.patrolControl++
case config.WorkflowPromptActivitySurfaceProActivation:
counts.proActivation++
counts.patrolControl++
case config.WorkflowPromptActivitySurfacePulseMCP:
counts.mcp++
}
}
return counts
}
func agentOperationsLoopActionEvidenceCounts(store unified.ResourceStore, since time.Time) agentOperationsLoopActionCounts {
var counts agentOperationsLoopActionCounts
if store == nil {
return counts
}
recentIDs := map[string]struct{}{}
governedIDs := map[string]struct{}{}
approvedIDs := map[string]struct{}{}
rejectedIDs := map[string]struct{}{}
verifiedIDs := map[string]struct{}{}
auditsByID := map[string]unified.ActionAuditRecord{}
cacheAudit := func(audit unified.ActionAuditRecord) {
actionID := strings.TrimSpace(audit.ID)
if actionID == "" {
return
}
auditsByID[actionID] = audit
}
addAuditEvidence := func(audit unified.ActionAuditRecord) {
actionID := strings.TrimSpace(audit.ID)
if actionID == "" {
return
}
recentIDs[actionID] = struct{}{}
if pulseIntelligenceActionWasApproved(audit) {
approvedIDs[actionID] = struct{}{}
}
if pulseIntelligenceActionWasRejected(audit) {
rejectedIDs[actionID] = struct{}{}
}
if agentOperationsLoopGovernedAction(audit) {
governedIDs[actionID] = struct{}{}
}
if agentOperationsLoopVerifiedAction(audit) {
verifiedIDs[actionID] = struct{}{}
}
}
auditForID := func(actionID string) (unified.ActionAuditRecord, bool) {
actionID = strings.TrimSpace(actionID)
if actionID == "" {
return unified.ActionAuditRecord{}, false
}
if audit, ok := auditsByID[actionID]; ok {
return audit, true
}
audit, ok, err := store.GetActionAudit(actionID)
if err != nil || !ok {
return unified.ActionAuditRecord{}, false
}
cacheAudit(audit)
return audit, true
}
if audits, err := store.GetActionAudits("", since, 0); err == nil {
for _, audit := range audits {
cacheAudit(audit)
addAuditEvidence(audit)
}
}
if events, err := store.GetActionLifecycleEvents("", since, 0); err == nil {
for _, event := range events {
actionID := strings.TrimSpace(event.ActionID)
if actionID == "" {
continue
}
recentIDs[actionID] = struct{}{}
audit, ok := auditForID(actionID)
if !ok {
continue
}
if pulseIntelligenceActionWasApproved(audit) {
approvedIDs[actionID] = struct{}{}
}
if pulseIntelligenceActionWasRejected(audit) {
rejectedIDs[actionID] = struct{}{}
}
if agentOperationsLoopGovernedAction(audit) {
governedIDs[actionID] = struct{}{}
}
if agentOperationsLoopVerifiedAction(audit) {
verifiedIDs[actionID] = struct{}{}
}
}
}
counts.recent = len(recentIDs)
counts.governed = len(governedIDs)
counts.approved = len(approvedIDs)
counts.rejected = len(rejectedIDs)
counts.verified = len(verifiedIDs)
return counts
}
func agentOperationsLoopGovernedAction(record unified.ActionAuditRecord) bool {
return pulseIntelligenceActionWasApproved(record) || pulseIntelligenceActionWasRejected(record)
}
func agentOperationsLoopVerifiedAction(record unified.ActionAuditRecord) bool {
return pulseIntelligenceActionVerifiedOutcome(record)
}
func agentOperationsLoopContextualCollaborationCount(status AgentOperationsLoopStatus) int {
return status.assistantContextCount + status.externalAgentCollaborationCount
}
func agentOperationsLoopPatrolControlProof(status AgentOperationsLoopStatus) telemetry.PulseIntelligencePatrolControlProof {
return telemetry.ClassifyPulseIntelligencePatrolControlProof(telemetry.PulseIntelligencePatrolControlProofInput{
PatrolControlStarterCount: status.PatrolControlLoopStarterCount,
PatrolIssueEvidenceCount: status.PatrolIssueEvidenceCount,
ContextualCollaborationCount: agentOperationsLoopContextualCollaborationCount(status),
ApprovedDecisionCount: status.ApprovedDecisionCount,
RejectedDecisionCount: status.RejectedDecisionCount,
VerifiedOutcomeCount: status.VerifiedOutcomeCount,
})
}
func agentOperationsLoopProgress(status AgentOperationsLoopStatus) (string, string, []AgentOperationsLoopStep) {
hasIssueEvidence := status.PatrolIssueEvidenceCount > 0
contextualCollaborationCount := agentOperationsLoopContextualCollaborationCount(status)
hasContextualCollaboration := contextualCollaborationCount > 0
hasApprovedDecision := status.ApprovedDecisionCount > 0 || status.VerifiedOutcomeCount > 0
hasRejectedDecision := status.RejectedDecisionCount > 0
hasGovernedDecision := hasApprovedDecision || hasRejectedDecision || status.GovernedActionCount > 0
hasGovernedWork := status.PendingApprovalCount > 0 || hasGovernedDecision
hasVerifiedOutcome := status.VerifiedOutcomeCount > 0
hasRejectedTerminalDecision := hasRejectedDecision && !hasApprovedDecision
hasPatrolControlCompletedLoop := status.PatrolControlCompletedLoopCount > 0
hasPatrolControlResolvedLoop := status.PatrolControlResolvedLoopCount > 0
governanceStepCount := status.PendingApprovalCount
if decisionCount := status.ApprovedDecisionCount + status.RejectedDecisionCount; decisionCount > 0 {
governanceStepCount = decisionCount
} else if status.GovernedActionCount > 0 {
governanceStepCount = status.GovernedActionCount
}
verificationStepCount := status.VerifiedOutcomeCount
if verificationStepCount == 0 && hasRejectedTerminalDecision {
verificationStepCount = status.RejectedDecisionCount
}
steps := []AgentOperationsLoopStep{
{ID: "patrol", Label: "Patrol", Status: AgentOperationsLoopStepPending, Count: status.PatrolIssueEvidenceCount},
{ID: "assistant", Label: "Assistant", Status: AgentOperationsLoopStepPending, Count: contextualCollaborationCount},
{ID: "governance", Label: "Governance", Status: AgentOperationsLoopStepPending, Count: governanceStepCount},
{ID: "verification", Label: "Verification", Status: AgentOperationsLoopStepPending, Count: verificationStepCount},
}
switch {
case !hasIssueEvidence:
steps[0].Status = AgentOperationsLoopStepCurrent
if status.PatrolControlLoopStarterCount > 0 {
return "run_patrol", "Patrol is ready to check the fleet and investigate the next real infrastructure issue.", steps
}
return "run_patrol", "Run Patrol to check for actionable infrastructure issues.", steps
case !hasContextualCollaboration:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepCurrent
return "open_assistant", "Open Assistant to explain the Patrol issue and safest next step.", steps
case !hasGovernedWork:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepCurrent
return "open_assistant", "Open Assistant to explain the Patrol issue and safest next step.", steps
case status.PendingApprovalCount > 0 && !hasGovernedDecision:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepComplete
steps[2].Status = AgentOperationsLoopStepCurrent
return "review_approvals", "Review the pending governed action approval.", steps
case status.PendingApprovalCount > 0:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepComplete
steps[2].Status = AgentOperationsLoopStepCurrent
return "review_approvals", "Review pending Patrol approvals before treating previous verified work as current.", steps
case status.ActiveFindingCount > 0 && hasVerifiedOutcome:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepCurrent
if status.ActiveFindingCount == 1 {
return "open_assistant", "Open Assistant on the active Patrol finding before treating previous verified work as current.", steps
}
return "review_findings", "Review active Patrol findings before treating previous verified work as current.", steps
case hasRejectedTerminalDecision:
for i := 0; i < 4; i++ {
steps[i].Status = AgentOperationsLoopStepComplete
}
if hasPatrolControlCompletedLoop {
return "complete", "Patrol recorded a rejected change decision. Nothing was changed; approve a safer fix before marking the issue resolved.", steps
}
return "complete", "Patrol recorded a governed rejection. Nothing was changed.", steps
case !hasVerifiedOutcome:
steps[0].Status = AgentOperationsLoopStepComplete
steps[1].Status = AgentOperationsLoopStepComplete
steps[2].Status = AgentOperationsLoopStepComplete
steps[3].Status = AgentOperationsLoopStepCurrent
if status.PatrolControlLoopStarterCount > 0 {
return "review_findings", "Verify whether the approved Patrol action fixed the issue.", steps
}
return "review_findings", "Verify whether the governed action fixed the issue.", steps
default:
for i := 0; i < 4; i++ {
steps[i].Status = AgentOperationsLoopStepComplete
}
if hasPatrolControlResolvedLoop {
return "complete", "Patrol handled an infrastructure issue, verified the outcome, and recorded what happened.", steps
}
return "complete", "Patrol handled an infrastructure issue, verified the outcome, and recorded what happened.", steps
}
}
func (h *AgentContextHandler) agentOperationsLoopExternalAgentReady(ctx context.Context, manifest agentcapabilities.Manifest, now time.Time) bool {
if !agentOperationsLoopExternalAgentManifestReady(manifest) {
return false
}
return h != nil &&
h.externalAgentReadinessProvider != nil &&
h.externalAgentReadinessProvider.ExternalAgentReady(ctx, manifest, now)
}
func agentOperationsLoopExternalAgentManifestReady(manifest agentcapabilities.Manifest) bool {
adapter := agentcapabilities.NormalizeMCPAdapterContract(manifest.MCPAdapter)
if strings.TrimSpace(adapter.Command) == "" ||
strings.TrimSpace(adapter.ServerName) == "" ||
strings.TrimSpace(adapter.TokenEnv) == "" ||
len(adapter.ConfigFamilies) == 0 {
return false
}
if !agentcapabilities.MCPManifestSurfacePromptProjectionSupported(manifest, agentcapabilities.SurfaceIDPulseMCP) {
return false
}
hasOperationsLoopPrompt := false
for _, prompt := range agentcapabilities.ManifestPulseWorkflowPrompts(manifest) {
if strings.TrimSpace(prompt.Name) == agentcapabilities.PulseWorkflowPromptOperationsLoop {
hasOperationsLoopPrompt = true
break
}
}
if !hasOperationsLoopPrompt {
return false
}
contract, ok := agentcapabilities.ProjectManifestSurfaceToolContract(manifest, agentcapabilities.SurfaceIDPulseMCP)
if !ok {
return false
}
toolNames := map[string]struct{}{}
for _, name := range contract.ToolNames {
name = strings.TrimSpace(name)
if name != "" {
toolNames[name] = struct{}{}
}
}
for _, name := range agentcapabilities.OperationsLoopCapabilityNames() {
if _, ok := toolNames[name]; !ok {
return false
}
}
return true
}
func agentOperationsLoopExternalAgentTokenReady(manifest agentcapabilities.Manifest, tokens []config.APITokenRecord, now time.Time) bool {
if !agentOperationsLoopExternalAgentManifestReady(manifest) {
return false
}
surfaceScopes := agentcapabilities.RequiredCapabilityScopes(
agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP),
)
if len(surfaceScopes) == 0 {
return false
}
for _, token := range tokens {
if token.ExpiresAt != nil && now.After(token.ExpiresAt.UTC()) {
continue
}
token = token.Clone()
coversLoop := true
for _, scope := range surfaceScopes {
if strings.TrimSpace(scope) == "" {
continue
}
if !token.HasScope(scope) {
coversLoop = false
break
}
}
if coversLoop {
return true
}
}
return false
}
// 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
}
canonical := unified.CanonicalActionResultV2(audit)
summary.ActionResultV2 = &canonical
legacy := unified.LegacyActionResultProjection(audit)
summary.Success = legacy.Success
summary.ErrorMessage = legacy.ErrorMessage
if audit.Result != nil && (strings.HasPrefix(audit.Result.ErrorMessage, "resource_remediation_locked:") || strings.HasPrefix(audit.Result.ErrorMessage, "plan_drift:")) {
summary.ErrorMessage = audit.Result.ErrorMessage
}
if v := projectAgentResourceVerification(unified.CanonicalActionVerification(audit)); 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,
}
}