Commit Graph

365 Commits

Author SHA1 Message Date
rcourtman 06fd4fc89e Bypass approval gate for trusted internal Discovery commands
Discovery wraps every probe in `docker exec <container> sh -c '...'`.
The agentexec command policy lists `^docker\s+exec\s` as RequireApproval
(a sound default for user-driven docker exec) and Discovery has no path
to mint or supply an ApprovalID. Result: every probe was rejected, the
scanner returned empty CommandOutputs, and the AI fell back to
"Unknown Infrastructure Resource" at confidence 0. The Discovery sub-tab
rendered empty after a "successful" run.

Add a Trusted bool to ExecuteCommandPayload on both the server-facing
agentexec type and the agent's wire struct. When set, the approval gate
is skipped on both ends and the server does not attempt to auto-mint an
approval grant (which would fail with "approval id is required").
PolicyBlock still applies; this is not a way to run arbitrary commands.

Only the discoveryCommandAdapter sets Trusted=true. The flag is never
populated from a deserialised HTTP body or any user-driven path. Patrol
fixes, Assistant remediation, and AI tool calls continue to flow through
the governed approval-record path with a real ApprovalID.

Contracts: amend agent-lifecycle Completion Obligations and Current
State to document the lone exception to the on-agent approval rail, and
amend ai-runtime to fence the Trusted flag to the discovery adapter
only.
2026-05-17 21:59:39 +01:00
rcourtman dc0635606d Keep Pulse runtime boundaries model-owned 2026-05-17 18:24:13 +01:00
rcourtman 251e8844dc Gate platform navigation by resource evidence 2026-05-17 18:16:15 +01:00
rcourtman 5e789f9a1d Align AI docs with model-owned reasoning 2026-05-17 17:59:22 +01:00
rcourtman abb6f86aeb frontend(platform-first): retire legacy top-level routes and cross-link affordances
Primary nav moved to platform-first (Proxmox/Docker/K8s/TrueNAS/vSphere) and
the standalone Infrastructure/Workloads/Storage/Recovery/Ceph routes were no
longer reachable from the UI — they only persisted as deep-link fallbacks and
as cross-jump targets for command palette / shortcuts / drawer links.

Code:
- RuntimeHome and NotFound redirect to /proxmox/overview; kiosk-mode
  redirect follows the same canonical home.
- App.tsx, routing/navigation.ts, routing/routePreload.ts, AppLayout.tsx
  drop the legacy routes, preloaders, tab IDs, and tab-title entries.
- Old page wrappers (Infrastructure/Workloads/Storage/Recovery/Ceph) and
  the orphan CephServiceIcon and workloadsLink helper are deleted; the
  underlying feature surfaces (InfrastructurePageSurface, WorkloadsSurface,
  StorageSurface, RecoverySurface, ProxmoxCephTable) stay embedded inside
  platform pages.
- Command palette and keyboard shortcuts now expose the five platform
  pages (g p/d/k/n/v) plus Patrol/Alerts/Settings; legacy g i/w/s/b are
  retired.
- K8s Namespaces/Deployments drawer "view pods" buttons route to
  /kubernetes/pods (the legacy ?context= + ?namespace= filter doesn't
  carry forward yet — the new sub-tab doesn't consume those params).
- Workload-row node-name click, host-row workloads icon, resource
  correlation/change drilldowns, and the PBS "Open Recovery Events" link
  render as plain text or are dropped — platform pages already surface
  the related data in adjacent sub-tabs.

Governance:
- Subsystem contracts updated to match: storage-recovery and
  performance-and-scalability drop the retired page wrappers from their
  Canonical Files lists; ai-runtime, cloud-paid, frontend-primitives, and
  unified-resources gain Current-State notes describing the platform-first
  redirect, palette/shortcut re-anchoring, and cross-link affordance
  removal so future work doesn't resurrect the retired paths.
- status.json L8 (First-session & UX polish) evidence: the retired
  pages/Infrastructure.tsx wrapper is replaced by pages/Proxmox.tsx as
  the canonical post-redirect landing surface.
- registry.json: 18 retired-file entries removed from owned_files,
  path-policy match_files, and exact_files across the route-shell-and-
  operations, workload-presentation, storage-recovery, and unified-
  resources subsystems. Surviving entries continue to cover the proof
  set — the embedded feature surfaces remain canonical and the retired
  page wrappers were thin shells with no remaining proof obligation.
- canonical_completion_guard_test.py and subsystem_lookup_test.py
  retargeted: the recovery and storage product-surface fixtures now
  exercise the canonical components/* surfaces instead of the retired
  pages/* shells, matching the new registry shape.

Tests: typecheck clean. Vitest 5820 pass / 6 pre-existing fail
(PageControls.guardrails, useStoragePoolDetailModel, ApprovalSection x3,
frontendResourceTypeBoundaries:1018) — all failing on HEAD before this
change with clean porcelain on the offending source files. Python
canonical_completion_guard_test (141) and subsystem_lookup_test (183)
green.
2026-05-16 18:57:37 +01:00
rcourtman f81490ca4f Keep discovery manual refresh visible 2026-05-15 23:55:22 +01:00
rcourtman 976e7c6b42 Add settings discovery refresh action
- expose a manual discovery sweep API

- wire Assistant & Patrol settings to run new, changed, and stale workload refreshes
2026-05-15 23:27:08 +01:00
rcourtman 2fbeb14541 frontend(platforms): make primary nav platform-first; demote infra/workloads/storage/recovery
Complete the platform-first navigation shift started in cdaeb3b84.
Primary navigation now enumerates exactly the supported platform
families (Proxmox, Docker, Kubernetes, TrueNAS, vSphere) and removes
Infrastructure, Workloads, Storage, and Recovery as equal top-level
tabs. Their tables continue to render inside each platform page via
the embedded tableOnly canonical surfaces; their routes remain wired
in App.tsx for deep-link compatibility, but they are not duplicated
in primary nav.

First-run UX decision: all supported platform tabs are alwaysShow:true
so first-run operators can discover what Pulse monitors. Unconnected
platforms render in a disabled tone (enabled/live derive from
canonical resource presence in state.resources), and the empty-state
inside the platform page itself surfaces the setup affordance. This
favors discoverability over hiding-until-connected, and is the
canonical decision recorded in the contract delta below.

Routing follow-up:
- routePreload.ts adds ROUTE_PRELOADERS entries for Docker, Kubernetes,
  TrueNAS, and vSphere so platform navigation stays warm on first paint.
- mobileNavBarModel.ts MOBILE_NAV_PLATFORM_PRIORITY mirrors the new
  platform-first primary set; legacy entries are removed.

Contracts updated:
- cloud-paid.md: records the platform-first primary-nav decision and
  the alwaysShow + presence-derived enabled/live contract; explicitly
  forbids reintroducing infra/workloads/storage/recovery as equal
  primary tabs without a governed contract decision.
- frontend-primitives.md: PlatformTab and MOBILE_NAV_PLATFORM_PRIORITY
  must mirror the supported-platform set; legacy entries intentionally
  absent.
- performance-and-scalability.md: every supported platform must be in
  the app-shell route preload registry; presentation-only platforms
  must not be registered.
- ai-runtime.md: demotion does not affect Patrol or Assistant
  addressability; platform pages must not replicate Patrol findings,
  Assistant prompts, or AI launcher affordances inside their chrome.

Verification:
- App.architecture.test.ts extended to assert the platform-first
  AppLayout structure (no infrastructure/workloads tabs, no
  buildStorageRecoveryTabSpecs call) and the new routePreload entries.
- MobileNavBar.test.tsx extended to assert the platform-first
  MOBILE_NAV_PLATFORM_PRIORITY ordering and the absence of legacy IDs.
- 68-platform-pages-shell.spec.ts (Playwright, chromium) re-run
  post-demotion: all 4 platform pages still render with sub-tab chrome
  against the live Pulse dev runtime in empty-resource state, proving
  the first-run / no-data nav stays discoverable.
- vitest sweep across src/features, src/__tests__,
  src/routing/__tests__ (351 tests) green. Pre-existing
  PageControls.guardrails failure is unrelated (verified by stashing).

Unrelated dirty files (other-agent work in internal/ai/, status.json,
ai-runtime.md further edits) intentionally left untouched.
2026-05-15 23:19:02 +01:00
rcourtman 6f37c8d0ef Align discovery prompt governance
- keep fallback Assistant governance aligned with discovery run support

- describe Patrol discovery as read-or-refresh evidence access
2026-05-15 23:11:59 +01:00
rcourtman cdaeb3b84d frontend(platforms): add Docker, Kubernetes, TrueNAS, vSphere platform pages
Introduce a shared platform-page primitive and four new top-level family
pages that mirror the v5-style Proxmox surface: chrome only, embedding
the canonical WorkloadsSurface, StorageSurface, RecoverySurface, and
UnifiedResourceTable in tableOnly/embedded mode with forced platform or
source filters. No dashboard cards, no fake data, no bespoke per-family
tables.

Pages added:
- /docker      Hosts / Containers / Swarm services
- /kubernetes  Clusters / Nodes / Pods / Deployments / Services
- /truenas     Hosts / Storage / Apps
- /vmware      Hosts / VMs / Storage (vSphere, first-lab-ready)

Top-level navigation entries are gated on platform presence in
state.resources, so empty platforms stay hidden by default; Proxmox
remains alwaysShow. Infrastructure, Workloads, Storage, and Recovery
remain available unchanged. Routing, navigation tab IDs, and the
document-title map are extended to match. Route preload and mobile-nav
priority changes are deliberately deferred to a follow-up commit to
avoid wider entanglement with parallel-agent edits to those shared
shell files.

Contracts extended for the new platform-page boundary:
- cloud-paid.md
- unified-resources.md
- storage-recovery.md
- ai-runtime.md
- frontend-primitives.md

Tests:
- dockerPageModel, kubernetesPageModel, truenasPageModel, vmwarePageModel
  vitest suites (12 new tests).
- resourceLinks.test.ts extended for the new platform path builders.
- App.architecture.test.ts extended for the new lazy imports and routes.
- 68-platform-pages-shell.spec.ts Playwright smoke covering all four
  pages and every sub-tab link against the live Pulse dev runtime.
- App.architecture, proxmox model, and Workloads suites continue to pass.

Skipped (canonical model not ready):
- Unraid as a top-level page: an agentHostProfile, no platform
  projections; already surfaces through the Pulse-managed Hosts / Storage
  views.
- Synology DSM, Microsoft Hyper-V, AWS, Azure, GCP:
  governanceState=presentation-only, no canonical projections.
2026-05-15 23:09:17 +01:00
rcourtman 6aad1118cd Align discovery with tool-led AI runtime
- add a forced run action to pulse_discovery for known resources

- make discovery progress describe model-backed evidence analysis rather than a live Assistant chat

- keep shared select hydration stable for persisted discovery intervals
2026-05-15 23:05:36 +01:00
rcourtman 6e8a7ae5fc Checkpoint current workspace progress 2026-05-15 22:45:48 +01:00
rcourtman d213ea3e29 Add Proxmox platform table metrics 2026-05-15 18:13:27 +01:00
rcourtman 303a8e2fe8 Remove Assistant handoff decision guidance 2026-05-15 16:57:25 +01:00
rcourtman 482b84e219 Remove Assistant pre-model context heuristics 2026-05-15 16:28:17 +01:00
rcourtman 14d3284233 Remove Assistant prompt routing heuristics 2026-05-15 16:10:58 +01:00
rcourtman 99fce8e5a3 Remove Assistant status routing residue 2026-05-15 15:19:26 +01:00
rcourtman 0561a1b5fa Remove Assistant and Patrol tool-routing heuristics 2026-05-15 14:51:15 +01:00
rcourtman eac7dfe9ef Make Patrol reasoning model-owned 2026-05-15 12:22:52 +01:00
rcourtman 895b78bd89 Make Assistant tool choice model-owned 2026-05-15 11:27:15 +01:00
rcourtman 348582df66 Fix Assistant chat model-owned routing 2026-05-15 10:50:23 +01:00
rcourtman 376b2f80fc Simplify Patrol Assistant handoffs 2026-05-15 10:17:10 +01:00
rcourtman 427b7ff5c6 fix: route patrol update safety through read state 2026-05-13 19:24:25 +01:00
rcourtman b213b59f7a Prioritize Patrol findings in Assistant handoffs 2026-05-12 22:46:53 +01:00
rcourtman 1726cf47b4 Harden Patrol and Assistant action boundaries 2026-05-12 12:06:27 +01:00
rcourtman d02255907c Fail closed when patrol_resolve_finding verifier is inconclusive
ResolveFinding adapter previously logged a warning and allowed the
LLM's resolve to proceed when the deterministic verifier returned
an error (timeout, executor unavailable, etc.). That's fail-open:
any verifier failure let the auto_resolved → re-detected cycle
continue, exactly the pattern the rest of this branch's
patrol_resolve_finding work spent commits closing. The "Backup
failed" finding on the live preview still cycled once post-
migration because of this path — verifier returned an
ErrVerificationUnknown and resolve was permitted.

Resolution of an event/persistent category finding is effectively
permanent (next detection registers as a regression and inflates
counters and pollutes the trust strip). When the deterministic
verifier cannot confidently say the failure signal is gone, we
don't have grounds to honor the LLM's judgment — the LLM's
"current investigation didn't surface a fresh failure" is exactly
the unreliable signal that produced bogus cycles.

Switches the inconclusive-verifier branch from log-and-allow to
log-and-reject, returning an error to the tool so the LLM can
retry or escalate to the operator. The verifier-still-detects-
signal path stays as-is (it was already fail-closed).

Test: TestPatrolFindingCreatorAdapter_ResolveFinding_RejectsWhenVerifierIsInconclusive
exercises the path by calling ResolveFinding on a backup-failed
finding through a PatrolService with no chat service wired
(getExecutorForVerification returns ErrVerificationUnknown). Asserts
the error mentions 'inconclusive' and that ResolvedAt remains nil.

Contract: extends the deterministic-resolve-gate clause in the
ai-runtime canonical-files completion-obligations to name the
fail-closed-on-inconclusive policy explicitly.
2026-05-11 10:53:06 +01:00
rcourtman 9f43a22fb1 Bound patrol-main session at 200 messages to stop unbounded disk growth
The patrol-main chat session was reused across every scheduled
Patrol run with no upper bound. After a month of runs the file
had grown to 16 MB / 3,593 messages, and every AddMessage
rewrote the whole file to disk — so the I/O cost per Patrol
run was scaling with total session age, not with the run's own
output. Across all chat sessions on this dev instance, the
ai_sessions directory hit 676 MB / 1,629 files.

The stateless-Patrol-input fix (commit 43760fb0d) stopped
loading the session back into the agentic loop, but Patrol
still wrote each run's messages to the session for the Pulse
Assistant sidebar's forensic view. That write path is what
this commit bounds.

ExecutePatrolStream now calls SessionStore.TrimMessages(200)
after each run, keeping roughly the last two runs' worth of
messages — enough for the sidebar to show recent activity, far
short of unbounded growth. The next Patrol run on a bloated
session will drop the historical 3,000+ messages down to 200
on its first write, so existing storage debt clears on its
own without a separate migration.

User-driven chat sessions are unaffected: TrimMessages with
keepMostRecent <= 0 is a no-op, and callers that want full
history retention simply don't call it. Only Patrol's
forensic session is capped.

The canonical Patrol forensic log is the PatrolRunRecord
history surfaced at /api/ai/patrol/runs — that's the durable
record with structured fields. The chat-session-shaped file
is a sidebar convenience, not the source of truth.

Three tests guard the boundary:
  - TrimMessages keeps the most recent N (50 messages
    trimmed to 10 → messages 40-49 remain)
  - TrimMessages is a no-op below threshold (5 messages,
    cap 200 → 5 messages remain)
  - TrimMessages with non-positive keep is a no-op (3
    messages, cap 0 or -5 → 3 messages remain)

ai-runtime contract updated.
2026-05-10 23:19:08 +01:00
rcourtman 5dcdbfabf0 Document chat-side cost recording in ai-runtime contract
Follow-up to a0b3bc7ed which closed the chat.Service cost-ledger
gap. ai-runtime.md gains a Current State paragraph documenting:
- The pre-fix bug (chat accumulated tokens via SSE done envelope
  but never recorded a cost.UsageEvent server-side; chat is the
  bulk of AI token spend so the dashboard was dramatically
  understating cost).
- The fix shape (recordChatTurnCost runs after every loop return,
  success or error since the operator was billed regardless).
- The threading path (chat.Config.CostStore wired by the router
  from AISettingsHandler.GetAIService.CostStore()).
- The double-recording invariant (ExecutePatrolStream is
  deliberately not changed; its caller patrol_ai.go records via
  its own helper).
- UseCase="chat" matches the canonical taxonomy noted on
  cost.UsageEvent.UseCase ("chat" or "patrol").
2026-05-10 23:16:47 +01:00
rcourtman 99c499ade7 Repair orphan tool_calls in convertToProviderMessages
Defense-in-depth for the malformed-history bug pattern. The
Patrol fix made patrol-main runs stateless, but Assistant
chat sessions are inherently multi-turn and must keep their
history. Any chat session that ends mid-tool-call — network
drop, ctx timeout, browser crash, uncaught panic, any
interrupt that fires between "model emits tool_calls" and
"agentic loop appends all tool results" — leaves the
persisted session with orphan tool_call_ids. The next message
that loads this history is rejected with the same provider
error that flapped Patrol for 33 days:

  An assistant message with 'tool_calls' must be followed by
  tool messages responding to each 'tool_call_id'.

For Patrol this was fixable by ignoring the session. For
Assistant it isn't; the conversation context is the product.

convertToProviderMessages now ends with a repairOrphanToolCalls
pass that scans every assistant message with tool_calls and
inserts synthetic is_error tool result messages immediately
after the assistant turn for any tool_call_id that has no
matching downstream result. The synthetic content is marked
is_error=true and explains the interruption so the model can
retry the same call or proceed without that data — preserving
conversational continuity while satisfying the provider's
structural-validity check.

This guards every conversation that crosses
convertToProviderMessages, not just Assistant chat. If Patrol
ever changes back to loading session history, the same safety
net applies. If a new entry point appears for some other LLM
flow, it gets the repair for free.

Three tests guard the boundary:
  - Orphan injection (3 tool_calls, only 1 result → 2
    synthetic results, marked is_error with interrupted
    explanation, ordering preserved)
  - Clean no-op (all tool_calls fulfilled → no synthetic
    messages, no is_error pollution)
  - Existing truncation test still passes (assistant message
    with both tool_calls and own tool_result → no repair
    needed, tool_call_id matches in same message)

ai-runtime contract updated.
2026-05-10 23:10:13 +01:00
rcourtman e657f6ace9 Suppress assessment error penalty after trailing-success recovery
The overall-health "Recent Patrol errors" coverage factor in
summarizeRecentPatrolCoverage was anchoring the score to a
stale ratio: it counted errors across the last 10 runs without
weighting recency. After Pulse fixed two compounding Patrol
bugs today, four consecutive successful runs (50+ tool calls
each) followed six earlier failures. The assessment kept
showing C/65 with the prediction "most recent Patrol runs
encountered errors (6 of 10)" — directly contradicting the
fact that *every* recent run had succeeded.

Operators reading that score would conclude Pulse Patrol is
still broken. It isn't. The fix dragged the grade.

This commit adds a recovery-suppression check: count trailing
successful full Patrol runs from the most-recent end of the
window (GetAll returns newest-first), skipping non-full runs.
When three or more consecutive trailing successes exist —
roughly a 9-hour clean stretch at the default 3-hour cadence —
the error penalty drops entirely. The score reflects current
reality.

Three is conservative: a single recovery run could be a
transient win; three consecutive demonstrate the underlying
fix is sticking. Below the threshold, the existing ratio-tiered
penalty still applies so partially-recovered states still
register.

Two tests guard the boundary:
  - 6 historical errors + 3 trailing successes → no coverage
    factor (suppressed)
  - 6 historical errors + 2 trailing successes → coverage
    factor remains (recovery incomplete)

Live verified after this commit lands: the assessment that's
been stuck at C/65 since the malformed-history fix will
recompute to A/B grade as soon as the trailing 3 successful
runs are recognized by the same recent-runs query.

ai-runtime contract updated.
2026-05-10 23:02:57 +01:00
rcourtman 15f6881d89 Document the chat-side narrator wiring in ai-runtime contract
Follow-up to 03463c1bf which threaded the per-tenant report
narrators through chat.Config -> tools.ExecutorConfig ->
PulseToolExecutor so pulse_summarize can produce AI-narrated
synthesis in chat instead of heuristic-only. ai-runtime.md's
Current State paragraph documents the wiring:
- chat.Config carries three optional fields (ReportNarrator,
  ReportFleetNarrator, ReportFindingsProvider) threaded through
  to the executor at session construction time.
- The router installs a SetReportNarratorResolver closure that
  mirrors the reporting handler's pattern, asking the
  AISettingsHandler for the per-tenant ai.Service and returning
  it as the implementation for all three roles when AI is
  enabled.
- Unconfigured tenants still get the heuristic fallback —
  matching the report PDF's graceful-degradation posture.
- AI-narrated chat synthesis uses the same provider, sanitizer,
  model selection, cost ledger (report_narrative /
  report_narrative_fleet use-cases), and budget gate the report
  PDF endpoint enforces, so there is exactly one canonical
  synthesis path for both surfaces.
2026-05-10 22:51:17 +01:00
rcourtman 7a7b3c9d30 Gate LLM patrol_resolve_finding on deterministic verifier for event findings
Backup-failed was flapping detected → auto-resolved → re-detected
ten times in a single day. Each cycle the LLM saw "PBS backups
look healthy in my current snapshot" during a Patrol pass, called
patrol_resolve_finding(backup-failed), and the adapter at
patrol_findings.go:985 called Resolve(findingID, true) directly —
no category check, no evidence verification.

The contract docs at findings.go:52-67 explicitly say event /
persistent categories (backup, reliability, security, general)
"stay active until explicitly resolved — either by the LLM calling
patrol_resolve_finding with evidence, or by operator action." That
"with evidence" was never enforced.

This commit enforces it. The adapter now checks two conditions
before honoring an LLM resolve:

  - finding.Category does NOT support stale-auto-resolve (per the
    contract function CategorySupportsStaleAutoResolve), AND
  - a deterministic verifier exists for finding.Key (currently
    smart-failure and backup-failed)

When both are true, the adapter runs VerifyFixResolved on the
finding's resource. If the verifier still detects the failure
signal, the LLM gets an error explaining why the resolve was
rejected and that the underlying issue must be fixed first. If
the verifier confirms the signal has cleared, the resolve
proceeds with grounded evidence.

Categories that support stale-auto-resolve (performance, capacity)
bypass the gate entirely — the LLM can resolve them based on
absence per the existing contract. Keys without a verifier also
fall through to current behavior so we don't block resolves for
categories we haven't built verifiers for yet.

New PatrolService.hasDeterministicVerifierForKey() helper keeps
the gate's verifier list in lockstep with the switch in
verifyFixDeterministically.

Tests cover the three branches:
  - performance category → gate skipped, resolve proceeds
  - reliability + no verifier → gate falls through, resolve proceeds
  - hasDeterministicVerifierForKey for known and unknown keys

ai-runtime contract updated.
2026-05-10 22:48:09 +01:00
rcourtman ee2de2703b Update ai-runtime contract to cover both absence-based auto-resolve paths
reconcileStaleFindings (commit b44d5892f) and the resource-absent
gate added in commit d6bb89a1c both use the same
CategorySupportsStaleAutoResolve helper, but the contract Current
State only documented the first path. Rewords the paragraph so
both are covered explicitly: stale-cleanup and resource-absent
both gate on the whitelist, both reject event/persistent
categories, and the bogus-absence examples extend to cover the
resource-absent failure mode (transient agent reconnect,
container churn, refresh gap).
2026-05-10 22:34:25 +01:00
rcourtman ac0f361372 Document the report-narrator detection-boundary invariant
Follow-up to the narrator prompt changes that forbid acting as a
parallel detector. ai-runtime.md gains a Current State paragraph
documenting that both report narrator system prompts encode an
explicit detection-boundary invariant: warning or critical severity
classifications must be backed by a Patrol finding, an alert, or a
hard-threshold breach in the structured input, not by metric
inference alone. Patterns the narrator notices without that backing
are constrained to info severity. This keeps the narrative
retrospective on Patrol's work and prevents silent
shadow-classification competing with Patrol's detection rules.

api-contracts.md gains an equivalent paragraph in the reporting
contract section. The same rule applies to outliers and patterns
on the fleet path, and to recommendations on both. The deterministic
heuristic narrators were already constrained to the same threshold
rules; this aligns the AI path with the same evidence surface the
fallback uses, so the report PDF cannot become a back-door
detection surface that diverges from the findings store.
2026-05-10 22:02:47 +01:00
rcourtman c6a786a36d Broaden regression-counter reset to include LLM-driven cycles
Initial detector (commit 942f9ca0f) only matched on the two legacy
absence-signature reason strings — but the Backup failed finding
on the live preview showed 6 auto_resolved events all with empty
messages, produced by the LLM patrol_resolve_finding tool via
Resolve(_, true). Counter stayed at 6× after the previous
migration ran.

New detector: any active finding whose category is NOT eligible
for stale-auto-resolve (i.e. anything other than performance or
capacity) AND has any auto_resolved event on its lifecycle is
treated as having an inflated counter. The rationale is the same
rule the category gate already established — for event/persistent
categories there is no legitimate absence-driven resolution path,
so any auto_resolved was either a removed-bogus-path stamp or an
LLM judgment call that repeatedly reverted through regressions on
the next run. The cumulative count is no signal either way.

Performance/capacity findings retain their counter because the
metric-cleared resolution model is sound there.

Test extended to cover four cases: LLM-driven cycle resets,
legacy-reason cycle resets, eligible-category preserves counter,
non-eligible category without any auto_resolved preserves counter.
Plus the existing idempotency case (already-reset finding stays
reset and is not re-applied).
2026-05-10 21:47:57 +01:00
rcourtman 942f9ca0f5 Reset regression counters polluted by bogus auto_resolve cycles
The Backup failed finding on the live preview showed "regressed 6×"
when the actual regression count of genuine recurrences was at
most 1 or 2 — the rest were the system fighting itself, driven by
the absence-based auto_resolve paths that were gated (category
whitelist) or removed (alert-mirror rip) earlier in this branch.
Counter stayed sticky after those fixes landed, so the trust strip
and finding badges still surfaced the inflated number.

FindingsStore.SetPersistence load pass now scans each active
finding's lifecycle for the two known bogus-signature auto_resolved
reasons ("No longer detected by patrol", "Resource no longer
exists in infrastructure"). If found, RegressionCount is reset to
0 and LastRegressionAt is cleared, and a regression_counter_reset
lifecycle event is appended so the migration is idempotent. A
finding that already has a regression_counter_reset event is left
alone; any regressed events that accrued after the reset are
genuine and stand.

findingHasBogusAutoResolveCycle returns true only when the
lifecycle contains a bogus auto_resolved and no prior reset event,
so the function is the single point of truth for the migration
decision and is straightforward to test. Test covers three cases:
finding with bogus signature gets reset, finding with empty-message
auto_resolved (LLM-driven, legitimate) keeps its counter, finding
already migrated is not re-reset.

Updates ai-runtime Current State to document the second migration
on top of the alert-mirror retirement.
2026-05-10 21:44:29 +01:00
rcourtman 590671ffbb Retire legacy "Active alert detected" findings on load
The previous commit removed the detectAlertSignals path so no NEW
alert-mirror findings are emitted, but the findings already
persisted from earlier builds stay in the store indefinitely —
nothing cleans them up (reconcileStaleFindings is gated on
performance/capacity categories, the LLM resolves them just to
have them re-detected next run except now the deterministic
emitter is gone so re-detection can't happen, but they're left
sitting as active findings draining the trust strip and score).

FindingsStore.SetPersistence now runs a one-shot retirement pass
on load: any active finding with title "Active alert detected",
source ai-analysis, and category general is auto-resolved with
reason "Patrol no longer mirrors alerts; the Alerts page is the
canonical surface for currently-firing alerts." The pass appends
an auto_resolved lifecycle event so the retirement is auditable,
syncs the loop state to resolved, and schedules a save so the
cleanup persists.

Idempotent: after the first load with this code, no findings
match the signature so the pass is a no-op. Defensive: the
signature requires all three fields (title + source + category)
to match before retiring, so an operator-authored finding that
happens to share the title is left untouched. Test covers the
mirror case, the matching-title-but-foreign-source case (must
NOT retire), and an unrelated active finding (must NOT retire),
plus verifies the retired state persists back through the
persistence layer.

Updates ai-runtime Current State to record the migration path.
2026-05-10 21:39:09 +01:00
rcourtman 271d12ecab Stop mirroring alerts into Patrol findings
The deterministic signal pipeline ran the pulse_alerts tool output
through detectAlertSignals and produced a SignalActiveAlert for
every firing alert, which Patrol then materialized as an
"Active alert detected" finding (source: ai-analysis, category:
general). The system prompt at the top of patrol_ai.go explicitly
tells the LLM not to duplicate alerts — but the deterministic
emitter was duplicating them anyway, behind the LLM's back.

Symptoms observed in the wild:
- 9 active "Active alert detected" findings in Patrol, every one a
  duplicate of an existing alert already on the Alerts page.
- The LLM, doing what the prompt told it, resolved each mirrored
  finding via patrol_resolve_finding. Next run the alert was still
  firing and Patrol re-emitted the signal → finding regressed.
  Lifecycle showed several auto_resolved → re-detected → regressed
  cycles per finding within hours.
- Health score dragged down by issues the operator already saw on
  the Alerts page, with no operator action possible from Patrol
  that wasn't already available from Alerts.

Rip detectAlertSignals entirely, remove the pulse_alerts case from
the signal-extraction switch, drop SignalActiveAlert plus its key
/ title / recommendation entries. Convert the prior
TestDetectSignals_ActiveAlert into a regression guard that locks
in the no-mirror behavior.

Updates the ai-runtime subsystem Current State to record the
decision: Patrol does not duplicate the Alerts surface; alerts
own their own lifecycle, surface, and acknowledgement model.
2026-05-10 21:33:41 +01:00
rcourtman 7bd596d378 Document the fleet-narrative surface in ai-runtime and api-contracts
Follow-up to the fleet-level AI narrative refactor: ai-runtime.md
gains a Current State paragraph documenting that internal/ai.Service
also implements pkg/reporting.FleetNarrator with its own use-case
label (report_narrative_fleet) so fleet vs single-resource spend is
distinguishable in the cost ledger and budget gate, and that the
single-resource narrator is intentionally not propagated through the
multi-report path. api-contracts.md gains a paragraph documenting
the new optional FleetNarrator field on MultiReportRequest, the new
FleetNarrative field on MultiReportData, the rendered fleet section
(executive prose, named outliers, cross-cutting patterns,
recommendations, optional period-comparison, AI provenance footer),
and the explicit invariant that the deterministic resource summary
table stays rendered from the same per-resource aggregates so every
named outlier is verifiable against the table below.

Dependent subsystems (agent-lifecycle, performance-and-scalability,
storage-recovery) remain unchanged: their Extension Points reference
internal/api/ broadly but agent lifecycle, perf scaling, and storage
recovery semantics have no delta from this change.
2026-05-10 21:24:04 +01:00
rcourtman 9905383f70 Document the report-narrative surface in ai-runtime and api-contracts
Follow-up to the report narrative refactor: ai-runtime.md gains a
Current State paragraph documenting that internal/ai.Service now
implements pkg/reporting.Narrator and pkg/reporting.FindingsProvider,
and that the narrator/findings surfaces inherit the same provider,
sanitizer, model selection, budget enforcement, and fail-closed
governance as the rest of the canonical AI runtime. api-contracts.md
gains a paragraph documenting the new optional Narrator and
FindingsProvider fields on MetricReportRequest, the new Narrative,
PriorPeriod, and Findings fields on ReportData, the three new PDF
sections (executive prose, Period-over-period changes, AI provenance
footer), and the explicit invariant that the deterministic data
surface (charts, stats, alerts, storage, disks) stays rendered from
the same aggregates so every AI claim is verifiable against adjacent
data. Multi-resource fleet reports intentionally remain heuristic-only
at this transport layer.

The dependent subsystems flagged by the staged-shape guard
(agent-lifecycle, performance-and-scalability, storage-recovery)
genuinely have no contract delta from the prior commit: their
Extension Points reference internal/api/ broadly, but agent
lifecycle, performance scaling, and storage recovery semantics are
unchanged.
2026-05-10 21:00:39 +01:00
rcourtman a343ba0126 Tier patrol-coverage health impact by recent-error ratio
The overall health score chip read "A · 95/100" while the same
assessment card said "Coverage incomplete · Recent Patrol runs
encountered errors · Verify full coverage." Those messages
contradicted because the "recent errors but had a successful run"
coverage factor used a flat -10 penalty regardless of the error
ratio. With one successful manual run among many failed startup
runs, the math stayed in the A band and the score directly
undermined the warning it sat next to.

The factor now tiers the impact by ratio of errored runs to
relevant runs in the scoring window:

  >50% errored → -30, "Most recent Patrol runs encountered errors
                       (N of M); the current health summary is not
                       reliable until coverage stabilizes."
  >25% errored → -20, "Recent Patrol runs encountered errors
                       (N of M), so the current health summary
                       may be incomplete."
  else         → -10, original light-tier description.

A single transient error stays in grade A; dominant-error periods
drop out of A so the grade matches the warning. Adds two tests
covering both ends of the new tiering and updates the ai-runtime
subsystem contract Current State section.
2026-05-10 19:33:18 +01:00
rcourtman b44d5892f4 Gate stale-finding auto-resolve on category whitelist
reconcileStaleFindings was auto-resolving any seeded finding that the
LLM didn't re-report in a successful run. The function's own comment
acknowledges the LLM doesn't reliably use patrol_resolve_finding, so
this was built as a cleanup pass — but it cannot tell the difference
between "LLM correctly recognized this is fixed" and "LLM forgot to
re-mention it." For findings that represent discrete events or
persistent states (a backup task that failed, a service that
crashed, a security vulnerability that was found, a configuration
error), absence in a Patrol report is not evidence that the issue
has cleared. The result was bogus auto_resolved → re-detected →
regressed cycles, observed in the wild as "Backup failed" regressing
4× over 6 hours and "Provider analysis error" regressing 271×.
Those bogus auto-resolutions also inflated the trust strip with
fictional auto-resolved credit.

CategorySupportsStaleAutoResolve in findings.go gates the cleanup:
only `performance` and `capacity` findings — continuous current-state
metric thresholds — may be auto-resolved from absence. The other
four categories (reliability, backup, security, general) stay active
until explicitly resolved.

Updates the ai-runtime subsystem contract Current State section with
the whitelist and the adjacent lifecycle dedup rules already landed.

Adds TestReconcileStaleFindings_SkipsNonCurrentStateCategories with
table-driven subtests for all four event/persistent categories, and
TestCategorySupportsStaleAutoResolve to lock in the whitelist.
2026-05-10 19:27:36 +01:00
rcourtman 43760fb0d0 Patrol runs are now stateless — drop prior session history
Patrol's "patrol-main" session was reused across every
scheduled run, so ExecutePatrolStream loaded the full
session history into the agentic loop's input. When any
prior run ended after the model emitted tool_calls but
before all tool results landed (provider error, timeout,
context cancellation), the orphan tool_calls were persisted
and every subsequent run inherited them. The provider then
rejected the conversation with:

  An assistant message with 'tool_calls' must be followed
  by tool messages responding to each 'tool_call_id'.
  (insufficient tool messages following tool_calls message)

Patrol failed 271 consecutive runs with this error before
it was diagnosed. Each new run added another user prompt
on top of the broken structure, so the message slice grew
to 33+ messages with one assistant turn at position 23
holding 4 orphan tool_call_ids and 9 user prompts stacked
after it.

The "patrol runs need a clean slate" comment at line 1898
documents that the knowledge accumulator is freshened per
run; the conversation history was the matching gap.
ExecutePatrolStream now passes only this run's user prompt
to the agentic loop. The session is still written to for
audit/forensics, just no longer fed back into the model.

Live verified: Patrol now completes successfully (18 tool
calls, 3m23s) on a session that previously failed every
run with the malformed-history error. The runtime-failure
finding auto-resolved on this same successful run.

Adds a classifier bucket for "insufficient tool messages"
errors so any future regression in this area surfaces with
a meaningful diagnostic instead of the generic
"Provider analysis error" fallback. New
PatrolFailureCauseMalformedToolHistory cause; predicate
patrolMalformedToolHistory matches DeepSeek's exact phrasing
and OpenAI's similar variants.

ai-runtime contract updated.
2026-05-10 17:14:47 +01:00
rcourtman 297556fb65 Surface cached preflight in Patrol tools readiness check
Previously the "Patrol tools" readiness check was static
model-name pattern matching: it told the operator whether
the selected model is on Pulse's tool-capable allowlist,
not whether tools have actually been verified to work.
After today's preflight cache, that's strictly less
information than what we already know.

resolvePatrolToolsCheck now consults
aiService.CachedPatrolPreflight() and grounds the check
in real evidence when a result for the configured
provider+model exists:

  - cached green (success + tool_call_observed) →
    "Tool calling verified <age> against <model>." (ready)
  - cached failure → classified summary + "(last preflight
    <age>)." (not_ready)
  - cached soft warning (model_tool_support_unverified) →
    same with warning status
  - no cache or model mismatch → static fallback

formatPatrolPreflightAge produces stable English ("just
now", "5m ago", "2h ago", "3d ago") with full unit
coverage (11 cases).

HandleGetAISettings now also includes patrol_readiness in
its response — previously only the PUT response carried it,
so the Patrol page only got augmented readiness after a
save. The frontend already had patrol_readiness typed and
read it from useAISettingsState.

ai-runtime, api-contracts, agent-lifecycle (dep), and
storage-recovery (dep) contracts updated.
2026-05-10 16:41:16 +01:00
rcourtman f74add8271 Auto-seed Patrol preflight cache on Pulse startup
Closes the cold-start gap in the preflight observability
layer: every Pulse restart blanked the cached "last verified"
indicator until the next save or manual click, which meant
operators saw "never verified" on every upgrade or process
restart even when the configured Patrol model was working
fine.

NewAISettingsHandler now reuses the existing
aiSettingsUpdateRequiresPatrolPreflight predicate with a nil
"prior config" — semantically "no in-memory cache yet, just
booted." When the loaded config has assistant enabled and a
Patrol model, the handler dispatches the same async
TriggerPatrolPreflightAsync the save path uses. Routine
boots where assistant is disabled (or no Patrol model is
selected) skip the dispatch so we never write a misleading
"Pulse Assistant is not enabled" entry into the cache.

Live verified: after Pulse restart, /api/settings/ai
surfaces a fresh patrol_preflight with success=true within
~6s of boot, no operator action required.

Predicate test extended with two named cases that document
the dual-purpose use (startup seed + skip-when-disabled).
ai-runtime, api-contracts, agent-lifecycle (dep), and
storage-recovery (dep) contracts updated.
2026-05-10 16:34:34 +01:00
rcourtman 33b6bf97d1 Auto-trigger Patrol preflight when settings save moves Patrol transport
Closes the resilience gap: today an operator picks a Patrol
model, saves, and Patrol silently fails on its first
scheduled run because nothing verified the model actually
calls tools. Now the save handler dispatches a one-shot
preflight in the background whenever the change actually
moved Patrol's transport, and the cached result surfaces on
the next /api/settings/ai poll via patrol_preflight.

Trigger conditions (aiSettingsUpdateRequiresPatrolPreflight):
  - new config has assistant enabled AND a Patrol model
  - AND any of:
      * no prior config (first save)
      * assistant was disabled, now enabled
      * Patrol model changed
      * API key for the new patrol model's provider changed

Routine saves that don't touch Patrol transport (theme,
control level, discovery toggles, unrelated provider keys)
skip preflight entirely so they don't burn provider tokens
or add 5-10s latency to every save.

Service-level TriggerPatrolPreflightAsync runs the call in a
goroutine with a 30s timeout. Detection helper has full
unit coverage including the negative paths.
2026-05-10 16:14:26 +01:00
rcourtman add1096ec8 Cache Patrol preflight outcome and hydrate UI on settings load
The Verify Patrol button reset its result to empty on every
page load — the operator had to re-click to see the verified
state, even though nothing had changed. This commit adds the
observability layer of the auto-preflight plan: every
RunPatrolToolPreflight result is now cached on the AI Service
and surfaced through /api/settings/ai as patrol_preflight, so
the inline result panel rehydrates on page load with the
most-recent outcome and a "last verified Xs ago" indicator.

Backend: patrolPreflightCache (mutex-guarded) on Service with
defensive-copy CachedPatrolPreflight() accessor; every
RunPatrolToolPreflight branch (success, soft warning, classified
failure, validation early-return) records into the cache.
PatrolPreflightSnapshot projects the cached result onto the
AI settings response. Tests cover both success-then-failure
supersession and the defensive-copy invariant.

Frontend: PatrolPreflightSnapshot type mirrors the wire shape;
hydratePatrolPreflightFromSettings(data) projects the snapshot
into the same response shape the manual button writes;
loadSettings and updateSettings flows call it. The result
panel renders a "last verified Xs ago" line under the
provider/model row when recorded_at_unix is present.

End-to-end smoke verified against deepseek-v4-flash: panel
rehydrates as green "Tool calling verified · last verified
just now" after page reload.

Auto-preflight on save (the trigger half of the resilience
plan) follows in the next commit.

Contracts: ai-runtime, api-contracts, agent-lifecycle (dep),
storage-recovery (dep), frontend-primitives all updated to
reflect the new patrol_preflight surface and hydration
contract. Verification artifacts: settingsArchitecture +
patrolPreflight client tests.
2026-05-10 14:58:26 +01:00
rcourtman e26a57a157 Add POST /api/ai/patrol/preflight tool-call verification
The existing per-provider /api/ai/test endpoints only call
ListModels — they pass for every provider that returns a
catalog, even when Patrol fails 100% of runs because tools
aren't actually wired up. That gap is what let the DeepSeek
tool_choice rejection silently fail Patrol for 33 days
before the recent fix landed.

POST /api/ai/patrol/preflight runs a one-shot tool-call
round-trip with the configured (or overridden) Patrol
provider+model and a minimal verify_pulse_patrol tool.
Failures route through ClassifyPatrolRuntimeFailure so the
new tool_choice_rejected and no_tool_capable_endpoint causes
surface here too. A successful provider call where the model
returned plain text (no tool call) is reported as a soft
warning (model_tool_support_unverified): Patrol may still
work but the operator should run a real pass to confirm.

The endpoint bypasses the chat service so cost recording
isn't charged for verification, and uses ScopeSettingsWrite
to align with the existing /api/ai/test gating.

Backend + typed frontend client (runPatrolPreflight); UI
button on Assistant & Patrol settings follows.

Contracts updated:
- ai-runtime: completion obligation extended to cover the
  new verification surface
- api-contracts: payload shape (tool_call_observed,
  duration_ms) noted in obligations
- agent-lifecycle, storage-recovery: dependent-extension
  acknowledgment that ai-runtime owns the new route despite
  it living under internal/api/
2026-05-10 14:30:41 +01:00
rcourtman f2d9d2aba8 Split overgreedy "tools not supported" classifier into three causes
The Patrol runtime classifier collapsed three distinct upstream
conditions into one misleading "Selected model does not support
Patrol tools" message:

  1. Provider rejected the *value* Pulse sent for tool selection
     (e.g. DeepSeek's "deepseek-reasoner does not support this
     tool_choice" — the model accepts tools, just not the forced
     coercion). The DeepSeek fix in 46145df9 dodges the symptom by
     coercing to auto, but the original misclassification pointed
     operators at the wrong remediation for 33 days.
  2. Provider has no tool-capable endpoint available for the
     selected model (OpenRouter's "No endpoints found …" surfaces
     this when account-level provider/data filters exclude every
     tool-capable route).
  3. Model truly lacks tool calling (the literal "tools are not
     supported" / "tool calling" cases).

Each now has its own PatrolFailureCause, title, summary,
description, and recommendation. summarizePatrolRuntimeFailureDetail
mirrors the split. Helper predicates patrolToolChoiceValueRejected
and patrolNoToolCapableEndpoint encapsulate the substring matching.

The OpenRouter "No endpoints found" test fixture now correctly
classifies as no_tool_capable_endpoint instead of
model_unsupported_tools — fixture updates in
patrol_runtime_failure_test.go, patrol_assistant_handoff_test.go,
and ai_handler_test.go reflect the more accurate diagnostic.
New tests cover the tool_choice_rejected and generic
model_unsupported_tools paths explicitly.

The ai-runtime contract is updated to note the classifier-split
obligation alongside the existing transport-shape obligation.
2026-05-10 14:10:18 +01:00
rcourtman 46145df925 Coerce DeepSeek tool_choice to "auto" so Patrol stops failing
DeepSeek's API server-side aliases deepseek-v4-flash and
deepseek-v4-pro to deepseek-reasoner, which rejects forced
tool_choice with HTTP 400 ("deepseek-reasoner does not support
this tool_choice"). Pulse's classifier then surfaced this as
"Selected model does not support Patrol tools," misdirecting
diagnosis to the model rather than the request shape.

supportsForcedToolChoice now returns false for any DeepSeek
client, so every DeepSeek model falls back to tool_choice
"auto" regardless of how DeepSeek routes the requested ID.
The ai-runtime contract is updated to match: the
provider-transport boundary now coerces forced tool_choice for
every direct DeepSeek model ID, not only unknown ones.

Patrol verified end-to-end: 20 tool calls, 9 findings, prior
runtime failure auto-resolved.
2026-05-10 00:04:13 +01:00