mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
371ce99694
commit
9d1fabef03
16 changed files with 579 additions and 57 deletions
|
|
@ -2424,7 +2424,7 @@
|
|||
},
|
||||
{
|
||||
"id": "RA27",
|
||||
"summary": "Patrol status owns server-authored runtime readiness: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status` checks, and known not-ready states must block manual Patrol runs before they become generic interrupted-analysis failures.",
|
||||
"summary": "Patrol readiness owns server-authored runtime and configuration safety: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status`, settings-save, and Patrol action errors, and known not-ready states must block readiness-sensitive settings saves plus manual, scheduled, and scoped Patrol runs before they become generic interrupted-analysis failures.",
|
||||
"kind": "invariant",
|
||||
"blocking_level": "repo-ready",
|
||||
"proof_type": "automated",
|
||||
|
|
@ -2438,6 +2438,21 @@
|
|||
],
|
||||
"release_gate_ids": [],
|
||||
"proof_commands": [
|
||||
{
|
||||
"id": "patrol-readiness-browser-contract-tests",
|
||||
"run": [
|
||||
"/usr/bin/env",
|
||||
"PULSE_E2E_USE_HOT_DEV=1",
|
||||
"PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1",
|
||||
"npm",
|
||||
"--prefix",
|
||||
"tests/integration",
|
||||
"test",
|
||||
"--",
|
||||
"tests/18-patrol-runtime-state.spec.ts",
|
||||
"--project=chromium"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-readiness-frontend-tests",
|
||||
"run": [
|
||||
|
|
@ -2451,6 +2466,28 @@
|
|||
"src/pages/__tests__/AIIntelligence.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-readiness-runtime-gate-tests",
|
||||
"run": [
|
||||
"go",
|
||||
"test",
|
||||
"./internal/ai",
|
||||
"-run",
|
||||
"RunPatrol_BlocksNotReadyRuntimeBeforeLLM|RunScopedPatrol_BlocksNotReadyRuntimeBeforeLLM",
|
||||
"-count=1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-readiness-settings-save-tests",
|
||||
"run": [
|
||||
"go",
|
||||
"test",
|
||||
"./internal/api",
|
||||
"-run",
|
||||
"UpdateSettingsRejectsNotReadyPatrolModel|UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness|HandleForcePatrol_BlocksNotReadyPatrolModel",
|
||||
"-count=1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-readiness-status-contract-tests",
|
||||
"run": [
|
||||
|
|
@ -2509,11 +2546,41 @@
|
|||
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/patrol_coverage_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/patrol_init.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/patrol_readiness.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/patrol_run.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/service.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_handlers.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_handlers_test.go",
|
||||
|
|
@ -2528,6 +2595,11 @@
|
|||
"repo": "pulse",
|
||||
"path": "internal/api/contract_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "tests/integration/tests/18-patrol-runtime-state.spec.ts",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ runtime cost control, and shared AI transport surfaces.
|
|||
Patrol status must also carry server-authored readiness for provider,
|
||||
model, settings-persistence, and tool-calling prerequisites so the UI can
|
||||
block known-bad manual Patrol runs before they become generic runtime
|
||||
failures.
|
||||
failures. The same `internal/ai` readiness evaluation must gate Patrol
|
||||
runtime admission directly: readiness-sensitive settings saves, manual run
|
||||
requests, scheduled ticks, and scoped alert/anomaly runs must fail or skip
|
||||
before LLM execution when the selected Patrol model/provider is known
|
||||
not-ready.
|
||||
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops
|
||||
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
|
||||
External provider-bound unified-resource context must enforce the same
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ product API routes free of maintainer commercial analytics.
|
|||
The Patrol status payload owns Patrol readiness as structured API state:
|
||||
provider/model/settings/tool prerequisites must travel as bounded readiness
|
||||
checks instead of frontend-only heuristics or generic analysis-failed text.
|
||||
`/api/settings/ai/update` and `/api/ai/patrol/run` must use that same
|
||||
`patrol_readiness_not_ready` error taxonomy when they reject a known-bad
|
||||
Patrol runtime configuration, with bounded `status`, `provider`, and
|
||||
`model` details where available.
|
||||
7. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary.
|
||||
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
|
|
|
|||
|
|
@ -84,8 +84,9 @@ Patrol-specific presentation helpers.
|
|||
managed credits or account-backed AI access.
|
||||
Server-authored Patrol readiness from the status payload is part of the
|
||||
Patrol product surface: warnings must be visible before a run starts, and
|
||||
known not-ready states must disable manual Patrol runs instead of letting
|
||||
operators discover provider/model/tool incompatibility through a failed run.
|
||||
known not-ready states must block readiness-sensitive settings saves plus
|
||||
manual, scheduled, and scoped Patrol runs instead of letting operators
|
||||
discover provider/model/tool incompatibility through a failed run.
|
||||
5. Keep customer-facing Patrol naming product-first: page titles, route chrome,
|
||||
summary copy, actions, and empty states should lead with `Patrol` or
|
||||
`Pulse Patrol` rather than generic `AI` branding. Reserve `AI` terminology
|
||||
|
|
@ -350,6 +351,12 @@ The advanced Patrol panel must stay within the desktop viewport, scroll its
|
|||
own contents to the Apply control, and surface the backend's concrete
|
||||
license/validation reason when a save is rejected instead of replacing it with
|
||||
a generic `Failed to save advanced settings` toast.
|
||||
The readiness contract now applies before Patrol work is admitted, not only
|
||||
after a page render: readiness-sensitive settings saves must reject known-bad
|
||||
Patrol runtime configurations, manual run requests must return the structured
|
||||
readiness reason if a stale UI still submits, and scheduled or scoped
|
||||
alert/anomaly runs must skip before calling the model while preserving the
|
||||
blocked reason in Patrol status.
|
||||
That same Patrol-owned presentation rule also applies to the findings empty
|
||||
state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat
|
||||
`0 active findings` as equivalent to "your infrastructure looks healthy" when
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ import { buildPatrolInvestigationContextSummary } from './patrolInvestigationCon
|
|||
|
||||
type PatrolTab = 'findings' | 'history';
|
||||
|
||||
const patrolErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
|
||||
export function usePatrolIntelligenceState() {
|
||||
const [initialSurfaceReady, setInitialSurfaceReady] = createSignal(false);
|
||||
const [activeTab, setActiveTab] = createSignal<PatrolTab>('findings');
|
||||
|
|
@ -190,7 +193,7 @@ export function usePatrolIntelligenceState() {
|
|||
} catch (err) {
|
||||
console.error('Failed to toggle patrol:', err);
|
||||
setPatrolEnabledLocal(previousValue);
|
||||
notificationStore.error('Failed to toggle patrol');
|
||||
notificationStore.error(patrolErrorMessage(err, 'Failed to toggle patrol'));
|
||||
} finally {
|
||||
setIsTogglingPatrol(false);
|
||||
}
|
||||
|
|
@ -223,7 +226,7 @@ export function usePatrolIntelligenceState() {
|
|||
} catch (err) {
|
||||
console.error('Failed to trigger patrol run:', err);
|
||||
setManualRunRequested(false);
|
||||
notificationStore.error('Failed to start patrol run');
|
||||
notificationStore.error(patrolErrorMessage(err, 'Failed to start patrol run'));
|
||||
clearSafetyTimer();
|
||||
} finally {
|
||||
setIsTriggeringPatrol(false);
|
||||
|
|
@ -239,7 +242,7 @@ export function usePatrolIntelligenceState() {
|
|||
setPatrolModel(updated.patrol_model || modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to update patrol model:', err);
|
||||
notificationStore.error('Failed to update patrol model');
|
||||
notificationStore.error(patrolErrorMessage(err, 'Failed to update patrol model'));
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
|
|
@ -256,7 +259,7 @@ export function usePatrolIntelligenceState() {
|
|||
refetchPatrolStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to update patrol interval:', err);
|
||||
notificationStore.error('Failed to update patrol schedule');
|
||||
notificationStore.error(patrolErrorMessage(err, 'Failed to update patrol schedule'));
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
|
|
@ -273,7 +276,7 @@ export function usePatrolIntelligenceState() {
|
|||
} catch (err) {
|
||||
console.error('Failed to update alert-triggered analysis:', err);
|
||||
setAlertTriggeredAnalysis(previous);
|
||||
notificationStore.error('Failed to update alert analysis setting');
|
||||
notificationStore.error(patrolErrorMessage(err, 'Failed to update alert analysis setting'));
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
|
|
@ -290,7 +293,9 @@ export function usePatrolIntelligenceState() {
|
|||
} catch (err) {
|
||||
console.error('Failed to update alert-triggered patrols:', err);
|
||||
setPatrolAlertTriggers(previous);
|
||||
notificationStore.error('Failed to update alert-triggered Patrol setting');
|
||||
notificationStore.error(
|
||||
patrolErrorMessage(err, 'Failed to update alert-triggered Patrol setting'),
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
|
|
@ -307,7 +312,9 @@ export function usePatrolIntelligenceState() {
|
|||
} catch (err) {
|
||||
console.error('Failed to update anomaly-triggered patrols:', err);
|
||||
setPatrolAnomalyTriggers(previous);
|
||||
notificationStore.error('Failed to update anomaly-triggered Patrol setting');
|
||||
notificationStore.error(
|
||||
patrolErrorMessage(err, 'Failed to update anomaly-triggered Patrol setting'),
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -491,6 +491,28 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('surfaces backend readiness rejection when a stale manual run request reaches the server', async () => {
|
||||
triggerPatrolRunMock.mockRejectedValue(
|
||||
new Error(
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
),
|
||||
);
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationErrorMock).toHaveBeenCalledWith(
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy Patrol status payloads without readiness compatible', async () => {
|
||||
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ readiness: undefined }));
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package ai
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -221,3 +222,91 @@ func TestPatrol_RunPatrol_AIUnavailableUsesProviderSettingsGuidance(t *testing.T
|
|||
t.Fatalf("blocked reason = %q, want %q", status.BlockedReason, patrolProviderNotConfiguredReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrol_RunPatrol_BlocksNotReadyRuntimeBeforeLLM(t *testing.T) {
|
||||
stateProvider := &mockStateProvider{
|
||||
state: models.StateSnapshot{
|
||||
Nodes: []models.Node{
|
||||
{ID: "node1", Name: "node1", Status: "online"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(nil, nil)
|
||||
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
svc.cfg = &config.AIConfig{
|
||||
Enabled: true,
|
||||
Model: model,
|
||||
PatrolModel: model,
|
||||
PatrolEnabled: true,
|
||||
OllamaBaseURL: "http://127.0.0.1:11434",
|
||||
}
|
||||
svc.provider = &mockProvider{}
|
||||
svc.SetChatService(&mockChatService{
|
||||
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
|
||||
t.Fatalf("ExecutePatrolStream should not be called when Patrol readiness blocks the run")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
ps := NewPatrolService(svc, stateProvider)
|
||||
svc.patrolService = ps
|
||||
svc.ReconfigurePatrol()
|
||||
|
||||
ps.runPatrol(context.Background())
|
||||
|
||||
status := ps.GetStatus()
|
||||
if status.BlockedReason == "" {
|
||||
t.Fatal("expected blocked reason")
|
||||
}
|
||||
if !strings.Contains(status.BlockedReason, "reasoning-only model family") {
|
||||
t.Fatalf("blocked reason = %q", status.BlockedReason)
|
||||
}
|
||||
if status.ErrorCount != 0 {
|
||||
t.Fatalf("expected readiness block not to record runtime errors, got %d", status.ErrorCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrol_RunScopedPatrol_BlocksNotReadyRuntimeBeforeLLM(t *testing.T) {
|
||||
stateProvider := &mockStateProvider{
|
||||
state: models.StateSnapshot{
|
||||
Nodes: []models.Node{
|
||||
{ID: "node1", Name: "node1", Status: "online"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(nil, nil)
|
||||
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
svc.cfg = &config.AIConfig{
|
||||
Enabled: true,
|
||||
Model: model,
|
||||
PatrolModel: model,
|
||||
PatrolEnabled: true,
|
||||
OllamaBaseURL: "http://127.0.0.1:11434",
|
||||
}
|
||||
svc.provider = &mockProvider{}
|
||||
svc.SetChatService(&mockChatService{
|
||||
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
|
||||
t.Fatalf("ExecutePatrolStream should not be called when scoped Patrol readiness blocks the run")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
ps := NewPatrolService(svc, stateProvider)
|
||||
svc.patrolService = ps
|
||||
svc.ReconfigurePatrol()
|
||||
|
||||
ps.runScopedPatrol(context.Background(), PatrolScope{
|
||||
Reason: TriggerReasonAlertFired,
|
||||
ResourceIDs: []string{"node1"},
|
||||
})
|
||||
|
||||
status := ps.GetStatus()
|
||||
if !strings.Contains(status.BlockedReason, "reasoning-only model family") {
|
||||
t.Fatalf("blocked reason = %q", status.BlockedReason)
|
||||
}
|
||||
if status.ErrorCount != 0 {
|
||||
t.Fatalf("expected readiness block not to record runtime errors, got %d", status.ErrorCount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ type PatrolConfig struct {
|
|||
AnalyzeKubernetes bool `json:"analyze_kubernetes"`
|
||||
// AnalyzePMG controls whether to analyze Proxmox Mail Gateway instances
|
||||
AnalyzePMG bool `json:"analyze_pmg"`
|
||||
// RuntimeBlockedReason explains why the configured Patrol runtime must not
|
||||
// execute model-backed runs even though the schedule may be enabled.
|
||||
RuntimeBlockedReason string `json:"runtime_blocked_reason,omitempty"`
|
||||
}
|
||||
|
||||
// GetInterval returns the effective patrol interval, handling migration from old config
|
||||
|
|
@ -292,9 +295,19 @@ func (p *PatrolService) SetUnifiedFindingResolver(cb func(findingID string)) {
|
|||
func (p *PatrolService) SetConfig(cfg PatrolConfig) {
|
||||
p.mu.Lock()
|
||||
oldInterval := p.config.GetInterval()
|
||||
oldBlockedReason := strings.TrimSpace(p.config.RuntimeBlockedReason)
|
||||
p.config = cfg
|
||||
newInterval := cfg.GetInterval()
|
||||
configCh := p.configChanged
|
||||
newBlockedReason := strings.TrimSpace(cfg.RuntimeBlockedReason)
|
||||
switch {
|
||||
case newBlockedReason != "":
|
||||
p.lastBlockedReason = newBlockedReason
|
||||
p.lastBlockedAt = time.Now()
|
||||
case oldBlockedReason != "" && p.lastBlockedReason == oldBlockedReason:
|
||||
p.lastBlockedReason = ""
|
||||
p.lastBlockedAt = time.Time{}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Signal config change if patrol is running and interval changed
|
||||
|
|
|
|||
92
internal/ai/patrol_readiness.go
Normal file
92
internal/ai/patrol_readiness.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
PatrolReadinessReady = "ready"
|
||||
PatrolReadinessWarning = "warning"
|
||||
PatrolReadinessNotReady = "not_ready"
|
||||
)
|
||||
|
||||
type PatrolConfigReadiness struct {
|
||||
Status string
|
||||
Ready bool
|
||||
Summary string
|
||||
Provider string
|
||||
Model string
|
||||
}
|
||||
|
||||
func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness {
|
||||
if cfg == nil {
|
||||
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse Assistant settings could not be loaded from persistence.")
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.")
|
||||
}
|
||||
if !cfg.IsConfigured() {
|
||||
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "No AI provider is configured for Patrol.")
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(cfg.GetPatrolModel())
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(cfg.GetChatModel())
|
||||
}
|
||||
provider, _ := config.ParseModelString(model)
|
||||
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
|
||||
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, "No concrete Patrol model is selected.")
|
||||
}
|
||||
if !cfg.HasProvider(provider) {
|
||||
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider))
|
||||
}
|
||||
|
||||
status, message := PatrolToolReadinessForModel(provider, model)
|
||||
if status == PatrolReadinessReady {
|
||||
message = "Patrol is ready to run tool-backed verification."
|
||||
}
|
||||
return patrolConfigReadiness(provider, model, status, message)
|
||||
}
|
||||
|
||||
func PatrolToolReadinessForModel(provider, model string) (string, string) {
|
||||
normalizedModel := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case strings.Contains(normalizedModel, "deepseek-r1") ||
|
||||
strings.Contains(normalizedModel, "/r1") ||
|
||||
strings.Contains(normalizedModel, ":r1") ||
|
||||
strings.Contains(normalizedModel, "reasoner") ||
|
||||
strings.Contains(normalizedModel, "qwq"):
|
||||
return PatrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
|
||||
case provider == config.AIProviderOpenRouter:
|
||||
return PatrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
|
||||
case provider == config.AIProviderOllama:
|
||||
return PatrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
|
||||
case provider == config.AIProviderDeepSeek:
|
||||
return PatrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling."
|
||||
default:
|
||||
return PatrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract."
|
||||
}
|
||||
}
|
||||
|
||||
func patrolConfigReadiness(provider, model, status, summary string) PatrolConfigReadiness {
|
||||
return PatrolConfigReadiness{
|
||||
Status: status,
|
||||
Ready: status != PatrolReadinessNotReady,
|
||||
Summary: summary,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PatrolRuntimeReadiness() PatrolConfigReadiness {
|
||||
if s == nil {
|
||||
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse AI runtime service is not available.")
|
||||
}
|
||||
s.mu.RLock()
|
||||
cfg := s.cfg
|
||||
s.mu.RUnlock()
|
||||
return EvaluatePatrolConfigReadiness(cfg)
|
||||
}
|
||||
|
|
@ -250,6 +250,11 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
|
|||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
if reason := strings.TrimSpace(cfg.RuntimeBlockedReason); reason != "" {
|
||||
p.setBlockedReason(reason)
|
||||
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping run - runtime readiness blocked")
|
||||
return
|
||||
}
|
||||
|
||||
if !p.tryStartRun("full") {
|
||||
return
|
||||
|
|
@ -615,6 +620,11 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
|
|||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
if reason := strings.TrimSpace(cfg.RuntimeBlockedReason); reason != "" {
|
||||
p.setBlockedReason(reason)
|
||||
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping scoped run - runtime readiness blocked")
|
||||
return
|
||||
}
|
||||
|
||||
if !p.tryStartRun("scoped") {
|
||||
// Re-queue with backoff if retries remain
|
||||
|
|
|
|||
|
|
@ -955,6 +955,28 @@ func (s *Service) getEffectivePatrolInterval(cfg *config.AIConfig) time.Duration
|
|||
return interval
|
||||
}
|
||||
|
||||
func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig {
|
||||
patrolCfg := DefaultPatrolConfig()
|
||||
if cfg == nil {
|
||||
patrolCfg.Enabled = false
|
||||
patrolCfg.RuntimeBlockedReason = "Pulse Assistant settings could not be loaded from persistence."
|
||||
return patrolCfg
|
||||
}
|
||||
|
||||
patrolCfg.Enabled = cfg.IsPatrolEnabled()
|
||||
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
|
||||
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
|
||||
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
|
||||
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
|
||||
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
|
||||
if patrolCfg.Enabled {
|
||||
if readiness := EvaluatePatrolConfigReadiness(cfg); !readiness.Ready {
|
||||
patrolCfg.RuntimeBlockedReason = readiness.Summary
|
||||
}
|
||||
}
|
||||
return patrolCfg
|
||||
}
|
||||
|
||||
// GetLicenseState returns the current license state and whether features are available
|
||||
func (s *Service) GetLicenseState() (string, bool) {
|
||||
s.mu.RLock()
|
||||
|
|
@ -1000,14 +1022,8 @@ func (s *Service) StartPatrol(ctx context.Context) {
|
|||
log.Info().Msg("Patrol safe remediation requires Pulse Pro license - fixes will require manual approval")
|
||||
}
|
||||
|
||||
// Configure patrol from AI config (preserve defaults for resource types not in AI config)
|
||||
patrolCfg := DefaultPatrolConfig()
|
||||
patrolCfg.Enabled = true
|
||||
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
|
||||
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
|
||||
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
|
||||
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
|
||||
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
|
||||
// Configure patrol from AI config (preserve defaults for resource types not in AI config).
|
||||
patrolCfg := s.patrolConfigFromAIConfig(cfg)
|
||||
patrol.SetConfig(patrolCfg)
|
||||
patrol.SetProactiveMode(cfg.UseProactiveThresholds)
|
||||
patrol.SetEventTriggerConfig(PatrolEventTriggerConfig{
|
||||
|
|
@ -1104,14 +1120,8 @@ func (s *Service) ReconfigurePatrol() {
|
|||
return
|
||||
}
|
||||
|
||||
// Update patrol configuration (preserve defaults for resource types not in AI config)
|
||||
patrolCfg := DefaultPatrolConfig()
|
||||
patrolCfg.Enabled = cfg.IsPatrolEnabled()
|
||||
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
|
||||
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
|
||||
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
|
||||
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
|
||||
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
|
||||
// Update patrol configuration (preserve defaults for resource types not in AI config).
|
||||
patrolCfg := s.patrolConfigFromAIConfig(cfg)
|
||||
patrol.SetConfig(patrolCfg)
|
||||
|
||||
// Update proactive threshold mode
|
||||
|
|
|
|||
|
|
@ -1673,6 +1673,33 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
|
|||
req.ClearOllamaPassword != nil
|
||||
}
|
||||
|
||||
func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
|
||||
return req.Enabled != nil ||
|
||||
req.Model != nil ||
|
||||
req.ChatModel != nil ||
|
||||
req.PatrolModel != nil ||
|
||||
req.PatrolEnabled != nil ||
|
||||
req.PatrolIntervalMinutes != nil ||
|
||||
req.AuthMethod != nil ||
|
||||
req.AnthropicAPIKey != nil ||
|
||||
req.OpenAIAPIKey != nil ||
|
||||
req.OpenRouterAPIKey != nil ||
|
||||
req.DeepSeekAPIKey != nil ||
|
||||
req.GeminiAPIKey != nil ||
|
||||
req.OllamaBaseURL != nil ||
|
||||
req.OllamaUsername != nil ||
|
||||
req.OllamaPassword != nil ||
|
||||
req.OpenAIBaseURL != nil ||
|
||||
req.ClearAnthropicKey != nil ||
|
||||
req.ClearOpenAIKey != nil ||
|
||||
req.ClearOpenRouterKey != nil ||
|
||||
req.ClearDeepSeekKey != nil ||
|
||||
req.ClearGeminiKey != nil ||
|
||||
req.ClearOllamaURL != nil ||
|
||||
req.ClearOllamaUsername != nil ||
|
||||
req.ClearOllamaPassword != nil
|
||||
}
|
||||
|
||||
// SetOnControlSettingsChange sets a callback to be invoked when control settings change
|
||||
// Used by Router to update MCP tool visibility without restarting AI chat
|
||||
func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) {
|
||||
|
|
@ -2695,6 +2722,13 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
settings.Model = resolvedModel
|
||||
}
|
||||
settings.NormalizeQuickstartModelAliases()
|
||||
if settings.IsPatrolEnabled() && aiSettingsUpdateTouchesPatrolReadiness(req) {
|
||||
readiness := ai.EvaluatePatrolConfigReadiness(settings)
|
||||
if !readiness.Ready {
|
||||
writePatrolReadinessNotReadyResponse(w, http.StatusBadRequest, readiness)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
|
||||
|
|
@ -4825,9 +4859,9 @@ type PatrolReadinessCheck struct {
|
|||
}
|
||||
|
||||
const (
|
||||
patrolReadinessReady = "ready"
|
||||
patrolReadinessWarning = "warning"
|
||||
patrolReadinessNotReady = "not_ready"
|
||||
patrolReadinessReady = ai.PatrolReadinessReady
|
||||
patrolReadinessWarning = ai.PatrolReadinessWarning
|
||||
patrolReadinessNotReady = ai.PatrolReadinessNotReady
|
||||
)
|
||||
|
||||
func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse {
|
||||
|
|
@ -4886,7 +4920,7 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService
|
|||
}
|
||||
addCheck("model", patrolReadinessReady, "Patrol model", "Patrol has a model selected from a configured provider.", "")
|
||||
|
||||
toolStatus, toolMessage := patrolToolReadinessForModel(provider, model)
|
||||
toolStatus, toolMessage := ai.PatrolToolReadinessForModel(provider, model)
|
||||
toolAction := ""
|
||||
if toolStatus != patrolReadinessReady {
|
||||
toolAction = "open_provider_settings"
|
||||
|
|
@ -4925,26 +4959,6 @@ func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessRes
|
|||
return &readiness
|
||||
}
|
||||
|
||||
func patrolToolReadinessForModel(provider, model string) (string, string) {
|
||||
normalizedModel := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case strings.Contains(normalizedModel, "deepseek-r1") ||
|
||||
strings.Contains(normalizedModel, "/r1") ||
|
||||
strings.Contains(normalizedModel, ":r1") ||
|
||||
strings.Contains(normalizedModel, "reasoner") ||
|
||||
strings.Contains(normalizedModel, "qwq"):
|
||||
return patrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
|
||||
case provider == config.AIProviderOpenRouter:
|
||||
return patrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
|
||||
case provider == config.AIProviderOllama:
|
||||
return patrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
|
||||
case provider == config.AIProviderDeepSeek:
|
||||
return patrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling."
|
||||
default:
|
||||
return patrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract."
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) getPatrolService(ctx context.Context) *ai.PatrolService {
|
||||
aiService := h.GetAIService(ctx)
|
||||
if aiService == nil {
|
||||
|
|
@ -4957,6 +4971,17 @@ func writePatrolServiceUnavailableResponse(w http.ResponseWriter) {
|
|||
writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil)
|
||||
}
|
||||
|
||||
func writePatrolReadinessNotReadyResponse(w http.ResponseWriter, statusCode int, readiness ai.PatrolConfigReadiness) {
|
||||
details := map[string]string{"status": readiness.Status}
|
||||
if readiness.Provider != "" {
|
||||
details["provider"] = readiness.Provider
|
||||
}
|
||||
if readiness.Model != "" {
|
||||
details["model"] = readiness.Model
|
||||
}
|
||||
writeErrorResponse(w, statusCode, "patrol_readiness_not_ready", readiness.Summary, details)
|
||||
}
|
||||
|
||||
func retireQuickstartPatrolStatus(status ai.PatrolStatus) ai.PatrolStatus {
|
||||
if isRetiredQuickstartBlockedReason(status.BlockedReason) {
|
||||
status.BlockedReason = ""
|
||||
|
|
@ -5319,6 +5344,10 @@ func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Req
|
|||
writePatrolServiceUnavailableResponse(w)
|
||||
return
|
||||
}
|
||||
if readiness := aiService.PatrolRuntimeReadiness(); !readiness.Ready {
|
||||
writePatrolReadinessNotReadyResponse(w, http.StatusConflict, readiness)
|
||||
return
|
||||
}
|
||||
|
||||
// Cadence cap: Community tier is limited to 1 patrol run per hour.
|
||||
// Patrol itself is free (ai_patrol), but higher cadence is gated behind Pro/Cloud.
|
||||
|
|
|
|||
|
|
@ -642,8 +642,24 @@ func TestHandleGetFindingsHistory_StartTimeFilter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func seedReadyAnthropicPatrolRuntime(t *testing.T, handler *AISettingsHandler) {
|
||||
t.Helper()
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
|
||||
aiCfg.AnthropicAPIKey = "test-key"
|
||||
if err := handler.defaultPersistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
if err := handler.defaultAIService.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForcePatrol_ConfigDisabled(t *testing.T) {
|
||||
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
|
||||
seedReadyAnthropicPatrolRuntime(t, handler)
|
||||
|
||||
cfg := ai.DefaultPatrolConfig()
|
||||
cfg.Enabled = false
|
||||
|
|
@ -662,8 +678,48 @@ func TestHandleForcePatrol_ConfigDisabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleForcePatrol_BlocksNotReadyPatrolModel(t *testing.T) {
|
||||
handler, _, _, _ := setupAIHandlerWithPatrol(t)
|
||||
|
||||
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = model
|
||||
aiCfg.PatrolModel = model
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
if err := handler.defaultPersistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
if err := handler.defaultAIService.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleForcePatrol(rec, req)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload APIError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode error payload: %v", err)
|
||||
}
|
||||
if payload.Code != "patrol_readiness_not_ready" {
|
||||
t.Fatalf("code = %q, want patrol_readiness_not_ready", payload.Code)
|
||||
}
|
||||
if !strings.Contains(payload.ErrorMessage, "reasoning-only model family") {
|
||||
t.Fatalf("error = %q", payload.ErrorMessage)
|
||||
}
|
||||
if payload.Details["model"] != model {
|
||||
t.Fatalf("model detail = %q, want %q", payload.Details["model"], model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForcePatrol_CommunityTierIgnoresRecentScopedActivityForFullPatrolRateLimit(t *testing.T) {
|
||||
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
|
||||
seedReadyAnthropicPatrolRuntime(t, handler)
|
||||
handler.defaultAIService.SetLicenseChecker(communityLicenseChecker{})
|
||||
|
||||
setUnexportedField(t, patrol, "lastActivity", time.Now().Add(-10*time.Minute))
|
||||
|
|
|
|||
|
|
@ -133,6 +133,68 @@ func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T)
|
|||
require.Equal(t, patrolReadinessNotReady, toolCheck.Status)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettingsRejectsNotReadyPatrolModel(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
body, err := json.Marshal(AISettingsUpdateRequest{
|
||||
Enabled: ptr(true),
|
||||
Model: ptr(model),
|
||||
PatrolModel: ptr(model),
|
||||
OllamaBaseURL: ptr("http://127.0.0.1:11434"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
var payload APIError
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, "patrol_readiness_not_ready", payload.Code)
|
||||
require.Contains(t, payload.ErrorMessage, "reasoning-only model family")
|
||||
require.Equal(t, patrolReadinessNotReady, payload.Details["status"])
|
||||
require.Equal(t, "ollama", payload.Details["provider"])
|
||||
require.Equal(t, model, payload.Details["model"])
|
||||
|
||||
persisted, err := persistence.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.False(t, persisted.Enabled)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = model
|
||||
aiCfg.PatrolModel = model
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
body, err := json.Marshal(AISettingsUpdateRequest{
|
||||
RequestTimeoutSeconds: ptr(120),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
persisted, err := persistence.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 120, persisted.RequestTimeoutSeconds)
|
||||
require.Equal(t, model, persisted.PatrolModel)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_PatrolReadinessBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -3624,12 +3624,12 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
api_match["matched_contract_references"],
|
||||
[
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 186,
|
||||
"heading_line": 106,
|
||||
}
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 190,
|
||||
"heading_line": 106,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const PATROL_BLOCK_REASON =
|
|||
"Connect a provider to power Pulse Assistant and Patrol.";
|
||||
const PATROL_AUTONOMY_PRO_REQUIRED =
|
||||
"Investigation and auto-fix require Pulse Pro. Community tier is limited to Monitor (findings-only) autonomy.";
|
||||
const PATROL_REASONING_ONLY_REJECTION =
|
||||
"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings.";
|
||||
|
||||
function todayAt(hours: number, minutes: number): string {
|
||||
const value = new Date();
|
||||
|
|
@ -638,6 +640,49 @@ test.describe("Patrol runtime-state browser contract", () => {
|
|||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows the server reason when a stale manual Patrol run is rejected", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("mobile-"),
|
||||
"Desktop-only Patrol runtime coverage",
|
||||
);
|
||||
|
||||
let manualRunRequests = 0;
|
||||
|
||||
await ensureAuthenticated(page);
|
||||
await mockScopedTriggerPatrolRuntimeState(page);
|
||||
await page.route("**/api/ai/patrol/run", async (route) => {
|
||||
manualRunRequests += 1;
|
||||
expect(route.request().method()).toBe("POST");
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: PATROL_REASONING_ONLY_REJECTION,
|
||||
code: "patrol_readiness_not_ready",
|
||||
status_code: 409,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
details: {
|
||||
status: "not_ready",
|
||||
provider: "ollama",
|
||||
model: "ollama:deepseek-r1:7b-llama-distill-q4_K_M",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const runButton = page.getByRole("button", { name: "Run Patrol" });
|
||||
await expect(runButton).toBeEnabled();
|
||||
await runButton.click();
|
||||
|
||||
await expect.poll(() => manualRunRequests).toBe(1);
|
||||
await expect(page.getByText(PATROL_REASONING_ONLY_REJECTION)).toBeVisible();
|
||||
await expect(page.getByText("Failed to start patrol run")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("surfaces scoped trigger context inside the summary and split trigger controls on the Patrol page", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue