mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
3853124a39
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.
852 lines
30 KiB
Go
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
|
|
}
|