Files
pulse/internal/ai/qualification/client.go
T
rcourtman 3853124a39 Keep Patrol action history consistent with recorded outcomes
Refresh durable investigation lifecycle from authoritative actions while
preserving completed evidence. Keep resolved history reviewable and label
recorded plan facts separately from action outcomes. Follow all resource
pages during qualification and record live approval, rejection and storage
semantic-review results. Integrate current main and preserve its alert
ordering correction.
2026-09-06 21:23:01 +01:00

852 lines
30 KiB
Go

package qualification
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
const subscriptionAgentPreflightEvidenceGrace = 15 * time.Second
const localProviderPreflightEvidenceGrace = 15 * time.Second
type ClientConfig struct {
BaseURL string
Username string
Password string
Timeout time.Duration
}
type PulseClient struct {
config ClientConfig
client *http.Client
}
func NewPulseClient(config ClientConfig) (*PulseClient, error) {
config.BaseURL = strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if config.BaseURL == "" {
return nil, errors.New("Pulse base URL is required")
}
parsed, err := url.Parse(config.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid Pulse base URL %q", config.BaseURL)
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("Pulse base URL must not contain credentials, query parameters, or a fragment")
}
if config.Timeout <= 0 {
config.Timeout = 10 * time.Minute
}
return &PulseClient{config: config, client: &http.Client{Timeout: config.Timeout}}, nil
}
type AISettings struct {
Enabled bool `json:"enabled"`
Model string `json:"model"`
PatrolModel string `json:"patrol_model"`
PatrolEnabled bool `json:"patrol_enabled"`
CodexSubscriptionEnabled bool `json:"codex_subscription_enabled"`
ClaudeSubscriptionEnabled bool `json:"claude_subscription_enabled"`
OpenAIBaseURL string `json:"openai_base_url"`
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
ZaiBaseURL string `json:"zai_base_url"`
PatrolPreflight *PatrolPreflightSnapshot `json:"patrol_preflight,omitempty"`
}
type PatrolPreflightSnapshot struct {
Success bool `json:"success"`
Provider string `json:"provider"`
Model string `json:"model"`
ToolCallObserved bool `json:"tool_call_observed"`
DurationMs int64 `json:"duration_ms"`
Cause string `json:"cause"`
Title string `json:"title"`
Summary string `json:"summary"`
Recommendation string `json:"recommendation"`
RecordedAt string `json:"recorded_at"`
RecordedAtUnix int64 `json:"recorded_at_unix"`
}
type PatrolPreflightResult struct {
Success bool `json:"success"`
Provider string `json:"provider"`
Model string `json:"model"`
ToolCallObserved bool `json:"tool_call_observed"`
DurationMs int64 `json:"duration_ms"`
Message string `json:"message"`
Cause string `json:"cause"`
Summary string `json:"summary"`
Recommendation string `json:"recommendation"`
}
// PatrolModelSuiteLease pins one configured Patrol route for a complete live
// qualification suite. Individual scenario runners only observe and assert
// this route; they never rewrite settings or retrigger provider preflight.
type PatrolModelSuiteLease struct {
Model string
Preflight PatrolPreflightResult
restore func(context.Context) error
closeOnce sync.Once
closeErr error
}
func (l *PatrolModelSuiteLease) Close(ctx context.Context) error {
if l == nil {
return nil
}
l.closeOnce.Do(func() {
if l.restore != nil {
l.closeErr = l.restore(ctx)
}
})
return l.closeErr
}
type PulseVersion struct {
Version string `json:"version"`
Build string `json:"build"`
Runtime string `json:"runtime"`
IsDevelopment bool `json:"isDevelopment"`
}
func (s AISettings) EffectivePatrolModel() string {
if value := strings.TrimSpace(s.PatrolModel); value != "" {
return value
}
return strings.TrimSpace(s.Model)
}
func patrolModelSuiteAcquisitionTimeout(provider string, requested time.Duration, settings AISettings) time.Duration {
minimum := time.Duration(0)
configuredLocalTimeout := (&config.AIConfig{RequestTimeoutSeconds: settings.RequestTimeoutSeconds}).GetRequestTimeout()
switch provider {
case string(providers.SubscriptionAgentCodex), string(providers.SubscriptionAgentClaude):
minimum = providers.SubscriptionAgentMinimumRequestTimeout + subscriptionAgentPreflightEvidenceGrace
case "ollama":
minimum = configuredLocalTimeout + localProviderPreflightEvidenceGrace
case "openai":
if config.IsCustomOpenAICompatibleEndpoint(settings.OpenAIBaseURL) {
minimum = configuredLocalTimeout + localProviderPreflightEvidenceGrace
}
}
if minimum <= 0 {
return requested
}
if requested < minimum {
return minimum
}
return requested
}
type PatrolReadiness struct {
Status string `json:"status"`
Ready bool `json:"ready"`
Summary string `json:"summary"`
Provider string `json:"provider"`
Model string `json:"model"`
}
type PatrolStatus struct {
RuntimeState string `json:"runtime_state"`
Running bool `json:"running"`
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
Readiness PatrolReadiness `json:"readiness"`
}
type PatrolAutonomy struct {
AutonomyLevel string `json:"autonomy_level"`
EffectiveAutonomyLevel string `json:"effective_autonomy_level"`
}
func (a PatrolAutonomy) Effective() string {
if value := strings.TrimSpace(a.EffectiveAutonomyLevel); value != "" {
return value
}
return strings.TrimSpace(a.AutonomyLevel)
}
type Resource struct {
ID string `json:"id"`
Type string `json:"type"`
Technology string `json:"technology"`
Name string `json:"name"`
Status string `json:"status"`
LastSeen any `json:"lastSeen,omitempty"`
Labels map[string]any `json:"labels,omitempty"`
Docker *DockerResource `json:"docker,omitempty"`
}
type DockerResource struct {
ContainerState string `json:"containerState,omitempty"`
Health string `json:"health,omitempty"`
RestartCount int `json:"restartCount,omitempty"`
}
type Finding struct {
ID string `json:"id"`
Key string `json:"key"`
Severity string `json:"severity"`
Category string `json:"category"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"`
Title string `json:"title"`
Description string `json:"description"`
Impact string `json:"impact,omitempty"`
Recommendation string `json:"recommendation"`
Evidence string `json:"evidence"`
Source string `json:"source,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
ToolName string `json:"tool_name"`
Input string `json:"input"`
Output string `json:"output"`
Success bool `json:"success"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
Duration int64 `json:"duration_ms"`
}
type PatrolFindingAssessment struct {
FindingID string `json:"finding_id"`
Verdict string `json:"verdict"`
Evidence string `json:"evidence"`
Reason string `json:"reason"`
AssessedAt time.Time `json:"assessed_at"`
}
type PatrolRun struct {
ID string `json:"id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
DurationMs int64 `json:"duration_ms"`
Type string `json:"type"`
TriggerReason string `json:"trigger_reason"`
ScopeResourceIDs []string `json:"scope_resource_ids,omitempty"`
EffectiveScopeResourceIDs []string `json:"effective_scope_resource_ids,omitempty"`
ResourcesChecked int `json:"resources_checked"`
NewFindings int `json:"new_findings"`
ExistingFindings int `json:"existing_findings"`
RejectedFindings int `json:"rejected_findings"`
ResolvedFindings int `json:"resolved_findings"`
FindingsSummary string `json:"findings_summary"`
FindingIDs []string `json:"finding_ids"`
FindingAssessments []PatrolFindingAssessment `json:"finding_assessments,omitempty"`
ErrorCount int `json:"error_count"`
Status string `json:"status"`
ErrorSummary string `json:"error_summary,omitempty"`
AIAnalysis string `json:"ai_analysis,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallCount int `json:"tool_call_count"`
}
func (c *PulseClient) request(ctx context.Context, method, path string, input, output any) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.config.BaseURL+path, body)
if err != nil {
return err
}
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(c.config.Username, c.config.Password)
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
return &HTTPError{StatusCode: resp.StatusCode, Path: path, Body: sanitizeArtifactText(string(payload))}
}
if output == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(output); err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
return nil
}
type HTTPError struct {
StatusCode int
Path string
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("Pulse API %s returned %d: %s", e.Path, e.StatusCode, e.Body)
}
func (c *PulseClient) Settings(ctx context.Context) (AISettings, error) {
var settings AISettings
err := c.request(ctx, http.MethodGet, "/api/settings/ai", nil, &settings)
return settings, err
}
func (c *PulseClient) Version(ctx context.Context) (PulseVersion, error) {
var version PulseVersion
err := c.request(ctx, http.MethodGet, "/api/version", nil, &version)
return version, err
}
func (c *PulseClient) Status(ctx context.Context) (PatrolStatus, error) {
var status PatrolStatus
err := c.request(ctx, http.MethodGet, "/api/ai/patrol/status", nil, &status)
return status, err
}
func (c *PulseClient) Autonomy(ctx context.Context) (PatrolAutonomy, error) {
var autonomy PatrolAutonomy
err := c.request(ctx, http.MethodGet, "/api/ai/patrol/autonomy", nil, &autonomy)
return autonomy, err
}
func (c *PulseClient) SetPatrolModel(ctx context.Context, model string) error {
return c.request(ctx, http.MethodPut, "/api/settings/ai/update", map[string]any{"patrol_model": strings.TrimSpace(model)}, nil)
}
func (c *PulseClient) OverridePatrolModel(ctx context.Context, model string) (func(context.Context) error, error) {
model = strings.TrimSpace(model)
if model == "" {
return func(context.Context) error { return nil }, nil
}
settings, err := c.Settings(ctx)
if err != nil {
return nil, err
}
previous := settings.PatrolModel
provider, _ := splitModel(model)
payload := make(map[string]any)
restorePayload := make(map[string]any)
if !settings.Enabled {
payload["enabled"] = true
restorePayload["enabled"] = settings.Enabled
}
if previous != model {
payload["patrol_model"] = model
restorePayload["patrol_model"] = previous
}
if provider == "codex-subscription" && !settings.CodexSubscriptionEnabled {
payload["codex_subscription_enabled"] = true
restorePayload["codex_subscription_enabled"] = settings.CodexSubscriptionEnabled
}
if provider == "claude-subscription" && !settings.ClaudeSubscriptionEnabled {
payload["claude_subscription_enabled"] = true
restorePayload["claude_subscription_enabled"] = settings.ClaudeSubscriptionEnabled
}
if len(payload) == 0 {
return func(context.Context) error { return nil }, nil
}
if err := c.request(ctx, http.MethodPut, "/api/settings/ai/update", payload, nil); err != nil {
return nil, err
}
return func(restoreCtx context.Context) error {
return c.request(restoreCtx, http.MethodPut, "/api/settings/ai/update", restorePayload, nil)
}, nil
}
// AcquirePatrolModelSuite pins model for an entire live suite and obtains one
// fresh tool-call preflight result before any scenario is provisioned. A model
// change already triggers Pulse's asynchronous preflight, so acquisition waits
// for that exact new cache record instead of issuing a duplicate provider call.
// When the requested route is already configured, acquisition performs one
// synchronous preflight to ensure the suite never relies on stale cache state.
func (c *PulseClient) AcquirePatrolModelSuite(ctx context.Context, model string, timeout, poll time.Duration) (*PatrolModelSuiteLease, error) {
if timeout <= 0 {
timeout = 45 * time.Second
}
if poll <= 0 {
poll = 250 * time.Millisecond
}
before, err := c.Settings(ctx)
if err != nil {
return nil, err
}
target := strings.TrimSpace(model)
if target == "" {
target = before.EffectivePatrolModel()
}
provider, bareModel := splitModel(target)
if provider == "" || bareModel == "" {
return nil, fmt.Errorf("Patrol qualification requires a concrete provider:model route, got %q", target)
}
timeout = patrolModelSuiteAcquisitionTimeout(provider, timeout, before)
routeChanged := !before.Enabled || before.EffectivePatrolModel() != target ||
(provider == "codex-subscription" && !before.CodexSubscriptionEnabled) ||
(provider == "claude-subscription" && !before.ClaudeSubscriptionEnabled)
previousPreflightUnix := int64(0)
previousPreflightWasTarget := false
if before.PatrolPreflight != nil {
previousPreflightUnix = before.PatrolPreflight.RecordedAtUnix
previousPreflightWasTarget = strings.EqualFold(before.PatrolPreflight.Provider, provider) && before.PatrolPreflight.Model == bareModel
}
if routeChanged && previousPreflightWasTarget {
freshnessDeadline := time.Now().Add(timeout)
for time.Now().Unix() <= previousPreflightUnix {
if time.Now().After(freshnessDeadline) {
return nil, fmt.Errorf("Patrol qualification could not establish a fresh preflight clock boundary after %s", time.Unix(previousPreflightUnix, 0).UTC().Format(time.RFC3339))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
restore, err := c.OverridePatrolModel(ctx, target)
if err != nil {
return nil, err
}
restoreOnError := func(acquireErr error) (*PatrolModelSuiteLease, error) {
restoreCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return nil, errors.Join(acquireErr, restore(restoreCtx))
}
var preflight PatrolPreflightResult
if routeChanged {
preflight, err = c.waitForPatrolPreflight(ctx, provider, bareModel, previousPreflightUnix, previousPreflightWasTarget, timeout, poll)
} else {
preflight, err = c.RunPatrolPreflight(ctx)
}
if err != nil {
return restoreOnError(err)
}
if err := validatePatrolPreflight(target, preflight); err != nil {
return restoreOnError(err)
}
settings, err := c.Settings(ctx)
if err != nil {
return restoreOnError(err)
}
status, err := c.Status(ctx)
if err != nil {
return restoreOnError(err)
}
if err := validatePatrolRoute(target, settings, status); err != nil {
return restoreOnError(err)
}
return &PatrolModelSuiteLease{Model: target, Preflight: preflight, restore: restore}, nil
}
func (c *PulseClient) RunPatrolPreflight(ctx context.Context) (PatrolPreflightResult, error) {
var result PatrolPreflightResult
err := c.request(ctx, http.MethodPost, "/api/ai/patrol/preflight", map[string]any{}, &result)
return result, err
}
func (c *PulseClient) waitForPatrolPreflight(ctx context.Context, provider, model string, afterUnix int64, requireNewer bool, timeout, poll time.Duration) (PatrolPreflightResult, error) {
deadline := time.Now().Add(timeout)
for {
settings, err := c.Settings(ctx)
if err == nil && settings.PatrolPreflight != nil {
snapshot := settings.PatrolPreflight
fresh := !requireNewer || snapshot.RecordedAtUnix > afterUnix
if fresh && strings.EqualFold(snapshot.Provider, provider) && snapshot.Model == model {
return PatrolPreflightResult{
Success: snapshot.Success, Provider: snapshot.Provider, Model: snapshot.Model,
ToolCallObserved: snapshot.ToolCallObserved, DurationMs: snapshot.DurationMs,
Message: snapshot.Summary, Cause: snapshot.Cause, Summary: snapshot.Title,
Recommendation: snapshot.Recommendation,
}, nil
}
}
if time.Now().After(deadline) {
return PatrolPreflightResult{}, fmt.Errorf("Patrol qualification preflight did not publish fresh evidence for %s:%s before %s", provider, model, deadline.UTC().Format(time.RFC3339))
}
select {
case <-ctx.Done():
return PatrolPreflightResult{}, ctx.Err()
case <-time.After(poll):
}
}
}
func validatePatrolPreflight(target string, result PatrolPreflightResult) error {
provider, model := splitModel(target)
if !strings.EqualFold(result.Provider, provider) || result.Model != model {
return fmt.Errorf("Patrol qualification preflight resolved %s:%s, expected %s", result.Provider, result.Model, target)
}
if result.Success && result.ToolCallObserved {
return nil
}
summary := strings.TrimSpace(result.Message)
if summary == "" {
summary = strings.TrimSpace(result.Summary)
}
if summary == "" {
summary = "provider did not complete the tool-call preflight"
}
if cause := strings.TrimSpace(result.Cause); cause != "" {
return fmt.Errorf("Patrol qualification preflight failed for %s (%s): %s", target, cause, summary)
}
return fmt.Errorf("Patrol qualification preflight failed for %s: %s", target, summary)
}
func validatePatrolRoute(expected string, settings AISettings, status PatrolStatus) error {
expected = strings.TrimSpace(expected)
if actual := settings.EffectivePatrolModel(); actual != expected {
return fmt.Errorf("configured Patrol model %q does not match qualification suite route %q", actual, expected)
}
provider, model := splitModel(expected)
if !strings.EqualFold(status.Readiness.Provider, provider) || status.Readiness.Model != expected && status.Readiness.Model != model {
return fmt.Errorf("Patrol readiness resolved %s:%s, expected %s", status.Readiness.Provider, status.Readiness.Model, expected)
}
if !status.Readiness.Ready || strings.EqualFold(status.Readiness.Provider, "demo") {
return fmt.Errorf("Patrol qualification suite route is not ready: %s", status.Readiness.Summary)
}
return nil
}
func (c *PulseClient) Resources(ctx context.Context) ([]Resource, error) {
var resources []Resource
for page := 1; ; page++ {
var response struct {
Data []Resource `json:"data"`
Meta struct {
TotalPages int `json:"totalPages"`
} `json:"meta"`
}
// The resource API caps each page at 100 regardless of the requested
// limit. Follow its pagination so later resources can converge too.
path := fmt.Sprintf("/api/resources?limit=100&page=%d", page)
if err := c.request(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
resources = append(resources, response.Data...)
if page >= response.Meta.TotalPages {
return resources, nil
}
}
}
func (c *PulseClient) WaitForResources(ctx context.Context, names map[string]string, timeout, poll time.Duration) (map[string]Resource, error) {
return c.WaitForResourcesMatching(ctx, names, timeout, poll, nil)
}
func (c *PulseClient) WaitForResourcesMatching(ctx context.Context, names map[string]string, timeout, poll time.Duration, validate func(map[string]Resource) error) (map[string]Resource, error) {
if timeout <= 0 {
timeout = 5 * time.Minute
}
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(timeout)
var lastValidationErr error
for {
resources, err := c.Resources(ctx)
if err == nil {
matched := make(map[string]Resource, len(names))
for alias, name := range names {
for _, resource := range resources {
if resource.Name == name {
matched[alias] = resource
break
}
}
}
if len(matched) == len(names) {
if validate == nil {
return matched, nil
}
if validationErr := validate(matched); validationErr == nil {
return matched, nil
} else {
lastValidationErr = validationErr
}
}
}
if time.Now().After(deadline) {
if lastValidationErr != nil {
return nil, fmt.Errorf("collection exposed exact resources but did not converge to scenario-owned fault state before %s: %w", deadline.UTC().Format(time.RFC3339), lastValidationErr)
}
return nil, fmt.Errorf("collection did not expose all %d exact resource names before %s", len(names), deadline.UTC().Format(time.RFC3339))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(poll):
}
}
}
func (c *PulseClient) Findings(ctx context.Context) ([]Finding, error) {
var findings []Finding
err := c.request(ctx, http.MethodGet, "/api/ai/patrol/findings", nil, &findings)
return findings, err
}
func (c *PulseClient) Runs(ctx context.Context) ([]PatrolRun, error) {
var runs []PatrolRun
err := c.request(ctx, http.MethodGet, "/api/ai/patrol/runs?limit=100&include=tool_calls", nil, &runs)
return runs, err
}
func (c *PulseClient) Run(ctx context.Context, runID string) (PatrolRun, error) {
var run PatrolRun
err := c.request(ctx, http.MethodGet, "/api/ai/patrol/runs/"+url.PathEscape(runID)+"?include=tool_calls", nil, &run)
return run, err
}
func (c *PulseClient) Trigger(ctx context.Context, resourceIDs []string, _ string) error {
var body any
if len(resourceIDs) > 0 {
body = map[string]any{"resource_ids": resourceIDs}
}
return c.request(ctx, http.MethodPost, "/api/ai/patrol/run", body, nil)
}
func (c *PulseClient) TriggerAndWait(ctx context.Context, resourceIDs []string, contextText string, timeout time.Duration) (PatrolRun, error) {
if timeout <= 0 {
timeout = 10 * time.Minute
}
deadline := time.Now().Add(timeout)
before, err := c.Runs(ctx)
if err != nil {
return PatrolRun{}, err
}
known := make(map[string]struct{}, len(before))
for _, run := range before {
known[run.ID] = struct{}{}
}
var triggeredAt time.Time
for {
triggeredAt = time.Now().UTC()
if triggerErr := c.Trigger(ctx, resourceIDs, contextText); triggerErr == nil {
break
} else {
var apiErr *HTTPError
if !errors.As(triggerErr, &apiErr) || apiErr.StatusCode != http.StatusConflict {
return PatrolRun{}, triggerErr
}
}
if time.Now().After(deadline) {
return PatrolRun{}, errors.New("Patrol remained busy until the qualification run timeout")
}
select {
case <-ctx.Done():
return PatrolRun{}, ctx.Err()
case <-time.After(2 * time.Second):
}
}
for {
runs, runErr := c.Runs(ctx)
if runErr == nil {
sort.Slice(runs, func(i, j int) bool { return runs[i].StartedAt.After(runs[j].StartedAt) })
for _, candidate := range runs {
if _, exists := known[candidate.ID]; exists || candidate.CompletedAt.IsZero() {
continue
}
if candidate.StartedAt.Before(triggeredAt.Add(-2 * time.Second)) {
continue
}
if len(resourceIDs) > 0 && !intersects(candidate.EffectiveScopeResourceIDs, resourceIDs) && !intersects(candidate.ScopeResourceIDs, resourceIDs) {
continue
}
return c.Run(ctx, candidate.ID)
}
}
if time.Now().After(deadline) {
return PatrolRun{}, fmt.Errorf("no completed Patrol run associated with trigger at %s", triggeredAt.Format(time.RFC3339Nano))
}
select {
case <-ctx.Done():
return PatrolRun{}, ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
func (c *PulseClient) Investigation(ctx context.Context, findingID string) (aicontracts.InvestigationSession, error) {
var result aicontracts.InvestigationSession
err := c.request(ctx, http.MethodGet, "/api/ai/findings/"+url.PathEscape(findingID)+"/investigation", nil, &result)
return result, err
}
func (c *PulseClient) WaitForInvestigation(ctx context.Context, findingID string, timeout time.Duration) (aicontracts.InvestigationSession, error) {
if timeout <= 0 {
timeout = 10 * time.Minute
}
deadline := time.Now().Add(timeout)
for {
investigation, err := c.Investigation(ctx, findingID)
if err == nil {
status := strings.ToLower(strings.TrimSpace(string(investigation.Status)))
if status != "" && status != "pending" && status != "running" {
return investigation, nil
}
} else {
var apiErr *HTTPError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
return aicontracts.InvestigationSession{}, err
}
}
if time.Now().After(deadline) {
return aicontracts.InvestigationSession{}, fmt.Errorf("investigation for finding %s did not complete", findingID)
}
select {
case <-ctx.Done():
return aicontracts.InvestigationSession{}, ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
type ActionProjection struct {
unifiedresources.ActionAuditRecord
Resource *struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"resource,omitempty"`
}
type ActionDetail struct {
Audit ActionProjection `json:"audit"`
Events []unifiedresources.ActionLifecycleEvent `json:"events"`
ReadOnly bool `json:"readOnly"`
}
func (c *PulseClient) Actions(ctx context.Context, view string) ([]ActionProjection, error) {
if view == "" {
view = "pending"
}
var response struct {
Actions []ActionProjection `json:"actions"`
}
err := c.request(ctx, http.MethodGet, "/api/actions?view="+url.QueryEscape(view)+"&limit=500", nil, &response)
return response.Actions, err
}
func (c *PulseClient) Action(ctx context.Context, actionID string) (ActionDetail, error) {
var detail ActionDetail
err := c.request(ctx, http.MethodGet, "/api/actions/"+url.PathEscape(actionID), nil, &detail)
return detail, err
}
func (c *PulseClient) DecideAction(ctx context.Context, actionID, outcome, reason, planHash string) (ActionProjection, error) {
var response struct {
Audit ActionProjection `json:"audit"`
}
err := c.request(ctx, http.MethodPost, "/api/actions/"+url.PathEscape(actionID)+"/decision", map[string]any{
"outcome": outcome,
"reason": reason,
"planHash": planHash,
}, &response)
return response.Audit, err
}
func (c *PulseClient) ExecuteAction(ctx context.Context, actionID, reason, planHash string) (ActionProjection, error) {
var response struct {
Audit ActionProjection `json:"audit"`
}
err := c.request(ctx, http.MethodPost, "/api/actions/"+url.PathEscape(actionID)+"/execute", map[string]any{
"reason": reason,
"planHash": planHash,
}, &response)
return response.Audit, err
}
func (c *PulseClient) WaitForAction(ctx context.Context, actionID string, timeout time.Duration) (ActionDetail, error) {
if timeout <= 0 {
timeout = 5 * time.Minute
}
deadline := time.Now().Add(timeout)
for {
detail, err := c.Action(ctx, actionID)
if err == nil {
switch detail.Audit.State {
case unifiedresources.ActionStateCompleted, unifiedresources.ActionStateFailed,
unifiedresources.ActionStateRejected, unifiedresources.ActionStateExpired:
return detail, nil
}
} else {
return ActionDetail{}, err
}
if time.Now().After(deadline) {
return ActionDetail{}, fmt.Errorf("action %s did not reach a terminal state", actionID)
}
select {
case <-ctx.Done():
return ActionDetail{}, ctx.Err()
case <-time.After(time.Second):
}
}
}
func filterRunFindings(before, after []Finding, run PatrolRun, resourceIDs map[string]Resource, triggeredAt time.Time) []Finding {
wanted := make(map[string]struct{}, len(run.FindingIDs))
for _, id := range run.FindingIDs {
wanted[id] = struct{}{}
}
beforeSeen := make(map[string]time.Time, len(before))
for _, finding := range before {
beforeSeen[finding.ID] = finding.LastSeenAt
}
resources := make(map[string]struct{}, len(resourceIDs))
for _, resource := range resourceIDs {
resources[resource.ID] = struct{}{}
}
var result []Finding
for _, finding := range after {
_, runOwned := wanted[finding.ID]
_, resourceOwned := resources[finding.ResourceID]
previousSeen, existedBefore := beforeSeen[finding.ID]
updated := (existedBefore && finding.LastSeenAt.After(previousSeen)) ||
(!existedBefore && (finding.LastSeenAt.After(triggeredAt.Add(-2*time.Second)) || finding.DetectedAt.After(triggeredAt.Add(-2*time.Second))))
if runOwned || (resourceOwned && updated) {
result = append(result, finding)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func intersects(left, right []string) bool {
wanted := make(map[string]struct{}, len(right))
for _, value := range right {
wanted[value] = struct{}{}
}
for _, value := range left {
if _, ok := wanted[value]; ok {
return true
}
}
return false
}