mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 12:13:28 +00:00
803 lines
28 KiB
Go
803 lines
28 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/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
|
)
|
|
|
|
const subscriptionAgentPreflightEvidenceGrace = 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"`
|
|
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) time.Duration {
|
|
if provider != string(providers.SubscriptionAgentCodex) && provider != string(providers.SubscriptionAgentClaude) {
|
|
return requested
|
|
}
|
|
minimum := providers.SubscriptionAgentMinimumRequestTimeout + subscriptionAgentPreflightEvidenceGrace
|
|
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 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)
|
|
|
|
routeChanged := 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, model)
|
|
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 response struct {
|
|
Data []Resource `json:"data"`
|
|
}
|
|
err := c.request(ctx, http.MethodGet, "/api/resources?limit=1000", nil, &response)
|
|
return response.Data, err
|
|
}
|
|
|
|
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) {
|
|
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{}{}
|
|
}
|
|
triggeredAt := time.Now().UTC()
|
|
if err := c.Trigger(ctx, resourceIDs, contextText); err != nil {
|
|
return PatrolRun{}, err
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Minute
|
|
}
|
|
deadline := time.Now().Add(timeout)
|
|
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
|
|
}
|