mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-26 08:07:48 +00:00
1488 lines
62 KiB
Go
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,
|
|
}
|
|
}
|