Commit Graph

5823 Commits

Author SHA1 Message Date
rcourtman 95e797eac1 Disclose what the agent install token authorizes
Add a 'What this token authorizes' card to the Pulse Agent install
token step (Unraid, Docker, Kubernetes, generic Linux/macOS/Windows
host). Mirrors the disclosure pattern from the PVE Auth flow so a
security-aware user knows up front:

- The token reports host telemetry only
- Read-only by default; assistant control and host shell stay off
  until opted in per host
- Revocable any time from Settings → Infrastructure → Tokens

Reframe the input placeholder from 'Token name (optional)' to
'Token name (optional label for your audit log)' so the optionality
clearly applies to the label, not the token itself.

Add a hover tooltip to TrueNAS and VMware 'Preview impact' buttons
explaining that they show the resources Pulse would ingest without
saving or starting polling.

Normalize the PVE coverage section header from 'Data collection' to
'Collection scope' to match the TrueNAS form's terminology.
2026-05-09 10:52:49 +01:00
rcourtman 014c36ca02 Warn operators before silently dismissing a recurring finding
If an operator hits "Dismiss: Not an issue" or "Dismiss: Expected" on a
finding that has already regressed twice or more, they may be permanently
suppressing something Pulse keeps re-detecting. The dismiss confirmation
panel now shows a non-blocking amber hint when regressionCount > 1 and the
selected reason is not_an_issue or expected_behavior, nudging them toward
the reminder-bearing will_fix_later path without blocking the dismiss.

The hint never appears for will_fix_later itself (already commitment-
tracking) or for findings with no prior regression, so it stays quiet on
ordinary triage. This is the operator-facing half of "Pulse learns from
dismissal patterns" — a recurring issue should not be silently buried.
2026-05-09 10:51:58 +01:00
rcourtman b8b4fb0439 Tighten add-infrastructure picker copy and ordering
Three small UX gaps surfaced when walking the add-infrastructure flow
as a user:

- Source picker scattered the Proxmox suite (PVE at position 3, PBS
  and PMG at positions 8 and 9). Group PVE, PBS, and PMG adjacent;
  put other API platforms next, then agent-install paths with named
  platforms before the generic Linux/macOS/Windows host card, then
  the network endpoint probe fallback.

- 'First lab ready' badge surfaced internal lane-stage jargon. Rename
  to 'Early support' so the user reads it as 'this works but has had
  limited production exposure' instead of internal QA terminology.

- TrueNAS connection form Name field carried a 'tower' placeholder.
  Tower is iconic Unraid; the placeholder was likely a copy-paste
  leftover. Use 'truenas-1' so the example matches the form's domain.

Update the two tests pinning the picker order and the badge label to
the new canonical state.
2026-05-09 10:48:25 +01:00
rcourtman 5cc2f61be0 Surface will_fix_later remind-at on dismiss confirm and dismissed rows
Slice 18 made will_fix_later a real operational commitment server-side, but
the new RemindAt field stayed invisible to operators until the reminder fired
a week later. This wires it through the API surface and renders it where the
operator decides and where they later revisit.

UnifiedFinding (Go and TS) and the Patrol Finding TS shape now carry
RemindAt / remind_at; router.go and AddFromAI mirror it like the other
user-feedback fields. FindingsPanel previews "Pulse will stay quiet for 7
days, then surface again on <date>" on the dismiss confirmation panel before
the operator confirms, badges dismissed-as-will_fix_later rows with
"Reminding <date>" in amber, and adds explanatory copy for the other two
dismissal reasons so all three paths feel deliberate rather than
undifferentiated.
2026-05-09 10:47:21 +01:00
rcourtman fb293169f7 Make will_fix_later an operational commitment, not silent shut-up
Before this change, all three dismissal reasons funnelled through the same
"swallow re-detection at same/lower severity" path in FindingsStore.Add, so
will_fix_later was functionally identical to expected_behavior — Pulse stayed
quiet forever despite the dismiss_finding tool literally telling the LLM
"Pulse Patrol will continue to monitor this issue."

Now will_fix_later sets Finding.RemindAt (default 7 days) at dismissal time.
Once RemindAt has passed, the next re-detection clears the dismissal and emits
a `reminded` lifecycle event so the operator sees their lapsed commitment
instead of a swallowed finding. expected_behavior keeps acknowledged-forever
semantics; not_an_issue keeps Suppressed=true. The LLM tool response now
surfaces the remind-at date so Patrol's conversational explanations stay
aligned with the contract.
2026-05-09 10:19:28 +01:00
rcourtman 7cdf8606ca Land the verification render that slice 17 documented but missed
Slice 17 (commit 0b1ce54ee) added contract obligations and tests
asserting that ResourceActionHistory.tsx surfaces the broker's
read-after-write verification outcome and that types/actionAudit.ts
exposes the TS ActionVerificationResult mirror. The contract docs and
tests landed, but the actual render and the TS type mirror were
uncommitted: the staged scope drifted between `git add` and
`git commit` and only 4 of 7 files made it onto the branch.

The source-text test passed in pre-commit because it reads the
working tree at test execution time, not the committed state — so the
slice landed an honest contract pinned against an absent
implementation. This commit lands the actual render and the TS type
mirror so the existing slice-17 contracts and tests now describe live
code instead of pending dirty edits.

Files added with no behavior change beyond what slice 17 already
documented:
- frontend-modern/src/types/actionAudit.ts: ActionVerificationResult
  interface (ran/command/output/success/ranAt/note) on the existing
  ActionAuditExecutionResult.
- frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx:
  the verification outcome row rendered when result.verification.ran
  is true, with emerald tone for verified and amber for failed.
- frontend-modern/src/components/Infrastructure/__tests__/ResourceActionHistory.verification.test.ts:
  source-text test pinning the render wiring (companion to the
  detail-drawer assertion already on the branch).
2026-05-09 10:06:09 +01:00
rcourtman 0b1ce54eec Render post-dispatch verification outcome on action history rows
The broker's read-after-write verification (ActionVerificationResult on
ExecutionResult) was being persisted by the backend but no operator
surface displayed it. Operators reviewing the action history saw only
"command exit 0" — not "Pulse confirmed the workload service is active
after dispatch."

Adds the TS mirror for ActionVerificationResult on
types/actionAudit.ts and renders it on each audit row in
ResourceActionHistory.tsx when verification.ran=true. The render shows:
- "Verified" or "Verification failed" badge tone
- The verification command Pulse ran (e.g. systemctl is-active 'nginx')
- The captured output verbatim
- An italic note when the broker recorded one (dispatch failure,
  non-zero exit code)

Tone is emerald for verified, amber for failed — matching the trust
palette used for the confidence badge on the patrol findings panel.
When verification.ran=false (no derivable check, or feature disabled
for the action class) nothing renders, so operators do not see
fabricated "verified" claims for actions where Pulse cannot read back.

Verification artifacts:
- ResourceActionHistory.verification.test.ts: source-text test pinning
  the verification render wiring on ResourceActionHistory.tsx.
- ResourceDetailDrawer.history.test.tsx: extends the existing
  contract-style assertions to pin verification rendering on the
  detail drawer's audit rows.
- actionAudit.test.ts: round-trips a verification block through the
  API client to pin the TS type mirror.

Adds new Completion Obligation #23 to unified-resources contract
pinning the canonical TS mirror location and the canonical render
component, and a parallel api-contracts paragraph pinning the
verification block on the action audit response.
2026-05-09 10:02:51 +01:00
rcourtman a597321801 Run class-derived verification check after successful dispatch
Closes the read-after-write piece of the user's act-with-control loop:
"Act with control: ask for approval, execute through Pulse tools, then
verify." Until now Pulse authored verification narrative copy at
approval time but never actually executed the check.

Adds ActionVerificationResult to internal/unifiedresources/actions.go
and embeds it as ExecutionResult.Verification so the existing
result_json column persists the outcome without a schema migration.
The struct records: did the check run, what command did Pulse send,
what came back, did it succeed, when, and any failure note.

Adds VerificationCommandForCommand in tools_control.go that derives
the per-class read-after-write check used by the broker:
- service-restart / service-start / service-reload / service-stop
  → systemctl is-active <unit>

Container classes (container-restart, container-stop) are
intentionally deferred to pulse_docker's existing tool-level
docker inspect verification — adding a broker-level dispatch would
double-run the same check.

executeCommandWithAudit now runs the derived verification command via
the same agent path immediately after a successful dispatch, captures
output and exit code, and writes the result onto
ExecutionResult.Verification before recordActionExecutionResult
persists. If the check returns non-zero, Verification.Success=false
with a Note explaining the exit code so the audit history honestly
shows that Pulse ran the action but couldn't confirm it took.

Verification is best-effort and class-scoped: unknown command shapes
leave Verification nil rather than fabricating a verified=true entry,
matching the no-fabrication boundary from Impact / preflight authoring.

Tests cover:
- VerificationCommandForCommand derives the right check per class,
  shell-escapes single quotes in unit names, and returns "", false
  for docker (deferred) and unknown commands.
- TestExecuteCommandWithAuditRunsClassDerivedVerificationAfterDispatch
- TestExecuteCommandWithAuditMarksVerificationFailedWhenReadbackDoesNotConfirm
- TestExecuteCommandWithAuditSkipsVerificationForUnclassifiedCommands

Updates ai-runtime contract with the read-after-write rule and
container-class deferral. Adds Completion Obligation #21 to
unified-resources pinning ExecutionResult.Verification shape and the
no-fabrication boundary. Extends TestActionExecutionContractStaysAPIOwned
to pin ActionVerificationResult struct and the Verification field on
ExecutionResult.
2026-05-09 09:48:40 +01:00
rcourtman 2dca000416 Author per-command-class preflight context for approval review
Operators about to approve a Pulse-driven action need to know what the
command actually touches before saying yes. Until now the preflight
shown at approval time was the same generic boilerplate for every
action: "approval is scoped, hash must match, etc." — useful safety
posture but no operational specifics. The dry-run summary said the
same thing for 99% of actions.

Adds classifyApprovalCommand and approvalCommandClassPreflightAdditions
in tools_control.go that bucket common Pulse remediation actions and
return hand-authored safety + verification additions per class:
- service-restart  (systemctl restart, service restart)
- service-stop     (systemctl stop, service stop)
- service-start    (systemctl start, service start)
- service-reload   (systemctl reload)
- container-restart (docker / podman restart)
- container-stop   (docker / podman stop)
- k8s-rollout-restart (kubectl rollout restart)

The additions name concrete operational facts the operator needs:
- For service-restart: "Service will be briefly unavailable; no other
  unit dependencies altered" plus verification "Read back
  systemctl is-active <unit>" and "tail journal for crash patterns".
- For container-restart: "Container will be briefly unavailable;
  image and volume mounts unchanged" plus "Read back via docker
  inspect" verification.
- For k8s-rollout-restart: "Pods rolled in waves per deployment
  strategy; PodDisruptionBudget continues to apply" plus "watch
  kubectl rollout status" verification.

approvalPreflight now appends class-specific safety and verification
content onto the existing default content rather than replacing it,
so the broker's structural safety posture (org scope, hash match,
single-use approval) remains visible alongside the operational copy.

Unknown command classes return nil/nil — no fabricated padding for
commands the bucket does not recognize. The default preflight stands
on its own, matching the no-fabrication rule that runs through the
rest of the trust-record arc.

Tests cover: each known class returns non-empty additions with the
expected operational tokens; unknown commands return nil; the
end-to-end approvalPreflight merge surfaces both default and
class-specific safety/verification entries. ai-runtime contract
pinned with the per-class enrichment rule and the no-fabrication
boundary for unknown classes.
2026-05-09 09:29:13 +01:00
rcourtman 643d5f2992 Redact known secret shapes from action audit log persistence
The action audit log is plaintext SQL. Operators sometimes paste
secrets into a natural-language `reason` field ("rotate the key
sk-abc123 because it leaked"), and command output sometimes echoes
tokens. Both flows previously persisted unredacted, leaving the
audit history as a leaked-credential surface.

Adds RedactAuditText / RedactAuditRecord in a new
internal/unifiedresources/audit_redaction.go with a curated regex set
that targets the credential shapes most likely to be real secrets:
- URL with embedded basic-auth credentials (https://user:pass@host)
- Authorization: Bearer <token> and x-api-key: <token> headers
- Query-string secret params (?api_key=, &token=, &access_token=)
- JSON-style secret fields ("api_key": "...", "password": "...")
- Env-style or CLI-style secret assignments (PASSWORD=, api_key=)
- OpenAI/Anthropic-style API keys (sk-...)

The set is intentionally narrower than the patrol-failure redactor in
internal/ai/patrol_runtime_failure.go: it does NOT strip arbitrary
URLs, because operators legitimately reference runbooks, ticket
links, and GitHub issues in audit reasons. Only patterns very likely
to be real secrets are touched.

Wired at the persistence boundary (top of each store method) so all
record paths get redaction uniformly:
- SQLiteResourceStore.RecordActionAudit
- SQLiteResourceStore.RecordActionExecutionStart
- SQLiteResourceStore.RecordActionExecutionResult
- MemoryStore.RecordActionAudit
- MemoryStore.RecordActionExecutionStart
- MemoryStore.RecordActionExecutionResult

Plan, Approvals, and identity fields are left alone — they are
produced by Pulse, not operators or external command output, so they
do not need redaction (and changing them would break PlanHash drift
detection).

Verification artifacts:
- audit_redaction_test.go: pattern-by-pattern coverage plus public-URL
  passthrough and empty-string passthrough; full RedactAuditRecord
  shape test (Reason + Params string values + Result output redacted;
  non-string Params and Plan untouched).
- code_standards_test.go: pins RedactAuditRecord call sites in
  store.go and helper signatures in audit_redaction.go so future
  refactors cannot silently bypass redaction.
- registry_test.go: cross-cutting integration test that exercises the
  registry-store boundary (MemoryStore.RecordActionAudit) and asserts
  redaction is applied while Plan fields stay untouched.

Adds new Completion Obligation #20 to unified-resources contract
pinning the redaction-at-persistence-boundary rule and the
no-touch-Plan-fields invariant.
2026-05-08 22:34:57 +01:00
rcourtman 5843a444cf Drop two stale Infrastructure PBS/PMG assertions
The mocked UnifiedResourceTable renders only resource.name joined by
commas, so the 'PBS' / 'PMG' plain-text checks were testing a
rendering path the test setup short-circuits. Keep the substantive
infra-table textContent and infra-summary count assertions.

The 'syncs source filter selection' test drove a labeled <select>
that no longer exists since Infrastructure migrated to the FilterBar
chip pattern. With the table mocked, the page-level test has no
filter surface to drive. The state -> URL coupling is now exercised
in FilterBar / source-filter unit tests, not at the page boundary.
2026-05-08 22:27:31 +01:00
rcourtman 2e0a841fd1 Relax 2 grammar-fragile boundary assertions to symbol-presence checks
The boundary test had two stale assertions failing because the
implementation grammar shifted while preserving the contract:

- usePatrolIntelligenceState now imports
  buildPatrolInvestigationContextSummary inside a multi-line grouped
  import. Replace the single-line literal match with two checks
  (symbol present + module present).

- patrolInvestigationContextModel now passes a precomputed
  commandCount through normalizeNonNegativeCount to formatCommandSummary
  instead of '.commands?.length ?? 0'. Assert the helper is invoked
  and the legacy '.commands.join' path is absent without prescribing
  the exact arg shape.

Both contracts are still enforced; the assertions just stop tracking
import formatting and helper-call argument shape.
2026-05-08 22:21:46 +01:00
rcourtman 22ce58cb9b Persist a refused audit record when plan drift is caught
Slice 13 added the drift refusal path but only logged at WARN level —
the audit history showed nothing, so an operator reviewing the action
trail could not see "Pulse caught this drift attempt." Now the drift
branch writes a Failed audit record with Result.ErrorMessage prefixed
"plan_drift:" and dispatches a Failed lifecycle event before returning
ErrActionPlanDrift.

The record carries the same Request, Plan, and Approvals snapshots that
a normal audit record would, so the operator-facing audit row shows
exactly what was attempted and what was approved. The "plan_drift:"
prefix is a stable token for downstream surfaces (audit UI filters,
alert rules) to distinguish drift refusals from generic execution
failures.

Extends TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan
to assert the audit record exists with State=Failed and the plan_drift
error message after refusal. Updates the code-standards snippet check
for ErrActionPlanDrift to match gofmt's actual alignment in actions.go.
ai-runtime contract pinned with the audit-record-on-drift rule.
2026-05-08 22:20:01 +01:00
rcourtman a9e652e05b Refuse action execution when approved plan hash drifts from payload
PlanHash existed on ActionPlan as the contract for "the operator
approved exactly this (command, target, reason) combination" but the
broker never validated it: at execute time the freshly-recomputed hash
was overwritten by the approved plan's hash via mergeApprovedActionPlan
without comparison. A drifted payload (LLM re-emits with different
command, agent ID changes between approval and execute, malicious
injection) would run under a stale approval.

Fix: at the dispatch boundary in executeCommandWithAudit, recompute the
approval-equivalent hash from the actual payload using approvalPlanHash
(same function used at approval-creation time, so direct comparison is
meaningful), compare to plan.PlanHash, and refuse with a new
ErrActionPlanDrift error when they differ. The drift refusal also logs
at WARN level with action_id, approval_id, and both hashes so audit
review can see when drift was caught.

When approvedHash is empty (older approval records or contract paths
that did not author one), validation is skipped and existing behavior
is preserved.

Adds two tests:
- TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan:
  approval is for "systemctl restart workload"; payload at execute
  time is "rm -rf /var/log/pulse"; expects ErrActionPlanDrift and no
  agent dispatch.
- TestExecuteCommandWithAuditAllowsMatchingPlanHash: same payload
  matches the approved hash; expects normal dispatch.

Updates two pre-existing tests that used stub PlanHash strings:
- TestExecuteCommandWithDeniedApprovalDoesNotDispatch: was testing the
  denial path with PlanHash:"sha256:test". Now uses the real approval-
  equivalent hash so denial fires (not drift) and the test still
  isolates the denial behavior.
- ControlledConsumesApprovedCommandWithResolvedRoutingTarget: same fix
  pattern.

Extends TestActionExecutionContractStaysAPIOwned in code_standards
test to pin ErrActionPlanDrift's existence in actions.go so future
refactors cannot silently downgrade drift into a generic error kind.

Native action path (executeNativeActionWithAudit) is left for a
follow-up: it has the same drift gap but uses actionPlanHashForParams
which is shaped differently from approvalPlanHash, so a sound fix
needs a coherent canonical hash function rather than just adding the
check. Contracts pinned in ai-runtime and unified-resources (new
Completion Obligation #20).
2026-05-08 22:11:31 +01:00
rcourtman 0204744a88 Route FindingsPanel investigation pointer through shared helper
The proposed-fix briefing path inlined a finding-side pointer check
that touched investigationOutcome, investigationSessionId, and
lastInvestigatedAt directly. The frontend-resource-type-boundaries
guardrail forbids FindingsPanel from reading
finding.investigationSessionId because the documented presentation
boundary keeps those reads inside aiFindingPresentation helpers.

Add hasFindingInvestigationHandoffPointer to aiFindingPresentation
(narrower than hasFindingInvestigationDetails: no investigationStatus
or investigationAttempts; includes lastInvestigatedAt) and use it in
FindingsPanel. The approval-side pointer is still combined inline at
the call site since pendingApprovalBriefing is not finding state.
2026-05-08 22:10:34 +01:00
rcourtman b6432ae550 Drop obsolete Show correlations toggle from drawer test
The 'Show correlations' / 'Hide correlations' button no longer exists
in ResourceDetailDrawerOverviewTab. Correlations now render inline
inside the expanded investigation context panel rather than behind a
separate toggle, so Storage 1 and dependency links are visible as
soon as Show context is clicked.

Remove the two-step expand sequence and the hidden-by-default
assertion. Keep all the substantive Storage 1 / dependency link /
Host 1 / disk-pressure / latest-change checks against the expanded
context panel directly.
2026-05-08 22:06:20 +01:00
rcourtman 9bda1c37bf Remove obsolete max_monitored_systems retired-pricing test
RETIRED_TRIAL_PRICING_FEATURES was deliberately narrowed in
pricingHandoff.ts to contain only trial_expired (the variable name
itself now says "TRIAL"). max_monitored_systems was moved out of the
retired set and is now a live paid-feature key that routes through
the self-hosted purchase-start flow, not the neutral Plans surface.

The "keeps retired monitored-system pricing handoffs" test asserted
behavior the implementation has explicitly invalidated. Remove it;
the trial_expired case still exercises the retired-feature path,
and the active paid-feature path is covered in pricingHandoff.test.ts.
2026-05-08 22:03:41 +01:00
rcourtman a4346f2e93 Move resolveAlertTargetType boundary to alertAssistantHandoffModel
The boundary test asserted that InvestigateAlertButton owned the
resolveAlertTargetType call. The button has been refactored to
delegate to buildAlertAssistantHandoff, and the handoff model now
owns that boundary. Update the assertion to follow.

Note: the same test block still has unrelated failing assertions
about FindingsPanel.tsx accessing finding.investigationSessionId
and finding.status === 'resolved' directly rather than through the
documented status helpers. That is real implementation drift and
needs a separate FindingsPanel cleanup pass; not bundled here so the
boundary delta stays scoped.
2026-05-08 21:59:40 +01:00
rcourtman 7a92602bcc Surface previous resolved fix on the finding card
The previousResolvedFixSummary captured at regression time (slice 7) is
already woven into the Assistant chat context as a "Previous Resolved
Fix" line, but the operator cannot see it without opening Assistant.
Render it directly on the expanded finding card so "what worked last
time" is visible inline alongside Description / Impact / Recommendation,
with emerald accent styling that reads as a positive operational
memory cue rather than another alert.

TS plumbing that was missing on the frontend after slice 7's backend
work:
- previous_resolved_fix_summary on UnifiedFindingRecord and PatrolFinding
  in api/ai.ts and api/patrol.ts
- previousResolvedFixSummary on the store-level UnifiedFinding
- normalizeUnifiedFindingRecord and normalizePatrolFindingRecord copy
  the field through

FindingsPanel renders the summary only when populated; findings
without a captured prior fix continue to show no extra row, matching
the no-fabrication rule.

Adds verification artifacts:
- Source-text test pinning the new render
- frontend api round-trip test for the field
- Boundary test asserting the patrol-context model does not absorb the
  per-finding memory shell into the per-record investigation
  presentation

Updates the api-contracts, ai-runtime, and patrol-intelligence
contracts to pin the TS mirror, the FindingsPanel render surface, and
the per-finding-shell vs per-record-presentation boundary.
2026-05-08 21:28:03 +01:00
rcourtman 07d1ab51d1 Surface trust metrics on the Patrol page
Wire FindingsStore.GetTrustSummary through PatrolService and the
patrol-status API into the Patrol page so the operator can scan
"is Pulse useful?" at a glance. Adds a small Trust strip above the
Findings/Runs tab bar that renders compact signals: fixes verified,
auto-resolved, dismissed-as-noise, dismissed-as-expected, currently
active, and regressed-at-least-once. The strip is hidden when every
signal is zero so a fresh install sees no empty pill.

Plumbing:
- PatrolService.GetFindingsTrustSummary accessor (delegates to the
  store-level method shipped in the prior slice)
- PatrolStatusResponse carries Trust *FindingsTrustSummary; populated
  from the active patrol service, omitted when no service is available
  (snapshot semantics, not lifetime totals)
- TS FindingsTrustSummary mirror in api/patrol.ts and a trust field on
  PatrolStatus
- PatrolIntelligenceWorkspace reads state.patrolStatus()?.trust and
  conditionally renders the strip

Verification artifacts:
- internal/ai/patrol_test.go: TestPatrolService_GetFindingsTrustSummary
- internal/api/contract_test.go: TestContract_PatrolStatusTrustJSONSnapshot
  pinning the canonical wire shape
- frontend-modern/src/api/__tests__/patrol.test.ts: round-trip test for
  the trust block on the patrol-status response
- frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts:
  source-text test pinning state.patrolStatus()?.trust read,
  aria-label, and field names so future strip additions go through
  the FindingsTrustSummary contract first.
- frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts:
  pins that the per-finding context model does not synthesize impact
  from trust counters; trust is an aggregate operator-page concern,
  not a per-finding text source.

Updates the api-contracts, ai-runtime, patrol-intelligence,
agent-lifecycle, frontend-primitives, and storage-recovery contracts
to pin the trust block's shape, the strip's contract-first rule, and
the scope boundary (trust counters are advisory operator context, not
enrollment/storage/recovery action authority).
2026-05-08 21:11:24 +01:00
rcourtman 0dd3f8bedb Surface per-endpoint reasons in cluster "no healthy nodes" error
When every cluster endpoint failed health, getHealthyClient wrapped
the failure as `no healthy nodes available in cluster X (all N
endpoints unreachable: [...])`, dropping the per-endpoint reason from
cc.lastError. The connections aggregator's auth-error regex
(401/403/unauthorized/forbidden/authentication/...) only sees the
outer message, so a token rejected with 401 on every endpoint of a
clustered PVE connection surfaced as `state: "unreachable"` /
`adapterHealth: "blocked"` instead of `state: "unauthorized"` /
`credentialStatus: "invalid"` — the same Settings → Connections
brokenness the rest of today's commits set out to remove.

Single-node `pve:pi` already classified the same kind of failure
correctly because its error came straight from the per-instance
client; only the cluster wrapper masked it.

Surface each unhealthy endpoint's already-sanitized reason in the
outer error. The "no healthy nodes available" prefix is preserved so
existing callers that test for it (monitor_polling_storage.go,
internal cluster_client passthroughs, existing tests) keep working.

Add a regression test covering both shapes:
- all endpoints failed auth → wrapped error contains
  "Authentication failed" so the aggregator regex now matches.
- endpoint with no recorded reason → wrapped error includes the
  fallback "no recorded reason" text rather than a bare URL.
2026-05-08 21:10:14 +01:00
rcourtman e7060bfcd1 Sweep three more stale test assertions to match canonical state
- recoveryCanonicalVocabulary: relax 'platform-first' iterator
  assertion from the expression-body callback shape '(platform) => ('
  to just '(platform) =>'. Both recovery sections now use block-body
  callbacks because they need a local for badge resolution. The
  guardrail's real intent is the 'platform' name over legacy
  'provider', not a specific callback shape.

- SuggestProfileModal: Settings Preview now interpolates
  getSourcePlatformLabel('docker') into the KNOWN_SETTINGS label,
  yielding 'Enable Docker / Podman monitoring' instead of the older
  'Enable Docker Monitoring'.

- UnifiedResourceTable.workloads-link: the Service Infrastructure
  dual-table renders in compact layoutMode in the test environment.
  Update column header expectations to compact equivalents:
  Datastores -> Stores, Action -> Open, Deferred -> Def.
2026-05-08 21:05:46 +01:00
rcourtman cff4226531 Pass stored fingerprint into PVE diagnostic test client
The /api/diagnostics handler builds its own test client per PVE node
to run a live connectivity probe. The PBS branch already passed
node.Fingerprint into the test client config, but the PVE branch did
not. With VerifySSL=true and a self-signed Proxmox cert (the standard
configuration), tlsutil.CreateHTTPClientWithTimeout falls into
default-secure mode and validates against the system CA chain, which
fails the handshake even when the actual poller — which DOES pass
the fingerprint — is connecting fine.

The result was that /api/diagnostics reported delly + pi as
"Failed to connect to Proxmox API" while /api/resources was happily
ingesting all 27 workloads from the same hosts. Mirror the PBS
branch by passing node.Fingerprint into the PVE testCfg so the
diagnostic probe uses the same TLS verification path as the runtime
poller.

Add a regression test that spins up an httptest TLS server, captures
its leaf cert SHA-256, configures a PVE instance with VerifySSL=true
and that fingerprint, and asserts computeDiagnostics reports
Connected=true. The pre-fix code fails this with a "tls: bad
certificate" handshake error.
2026-05-08 20:58:32 +01:00
rcourtman a801bbf810 Add FindingsStore.GetTrustSummary snapshot for trust metrics
Adds FindingsTrustSummary struct and GetTrustSummary() method that
walks the in-memory findings store and returns a snapshot of how
currently-tracked findings have resolved: tracked, currently-active,
resolved (auto-resolved subset), fix-verified vs fix-failed, dismissed
broken out by reason (noise / expected / later), suppressed, and
regressed-at-least-once.

This is the data layer for the user's "trust metrics" arc — concrete
counts an operator can use to answer "do I trust Patrol?" Operators see
fix-verified and dismissed-as-noise grow over time as Patrol's analysis
gets sharper. The summary is intentionally a snapshot, not lifetime
totals; once findings are cleaned up they no longer contribute, so the
AutoResolved counter (which includes both Resolve(auto=true) and
UpdateInvestigationOutcome(fix_verified) paths) is a current-state
distribution, not a historical aggregate. The struct doc string is
explicit about this so downstream surfaces do not misframe it.

Adds a unit test covering each bucket (active, auto-resolved,
fix-verified, fix-failed, dismissed-as-noise, dismissed-as-expected,
regressed) with a fixture that exercises the real lifecycle methods
rather than mutating the store directly. Updates the ai-runtime
contract to pin the snapshot semantics and the AutoResolved-path
union.
2026-05-08 20:06:36 +01:00
rcourtman 9bb82202cf Align infrastructurePageModel sourceOptions to canonical order
The two failing assertions hardcoded sourceOptions with proxmox-pve
first, but buildSourcePlatformOptions sorts by
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER from platformSupportManifest:
agent, truenas, proxmox-pve, proxmox-pbs, proxmox-pmg, docker,
kubernetes. Reorder the expected arrays to match (agent before
proxmox-pve in the present-source case, truenas before proxmox-pve
in the route-owned case).
2026-05-08 20:01:15 +01:00
rcourtman 8fd20f2222 Add Explain contextual button to findings
Adds a contextual "Explain" entry point next to the existing
"Discuss with Assistant" button on every finding card. The new button
opens Assistant with the same handoff context (investigation record,
operational memory, pending approval, proposed fix) but seeds a
different leading sentence: "Explain this Patrol finding... Walk me
through what we know, why it matters for the affected workloads, how
confident the analysis is, and whether the recommended action is the
right next step." This routes the LLM toward an explanatory framing
rather than open-ended discussion, matching the user's vision of
specific contextual entry points instead of a single generic chat
button.

Plumbing changes:
- New PatrolAssistantFindingIntent type ('discuss' | 'explain')
- Optional intent on PatrolAssistantFindingPromptInput and
  PatrolAssistantFindingHandoffInput
- buildPatrolAssistantFindingPrompt switches the leading sentence on
  intent; downstream context attachment is identical so trust signals
  (impact, confidence, previous resolved fix, etc.) flow through both
  paths uniformly.
- FindingsPanel extracts the shared handoff into a small
  openFindingInAssistant(finding, intent) helper used by both
  handleDiscussWithAssistant and the new handleExplainFinding.

Adds two tests: explain-intent prompt has explanation framing while
discuss-intent keeps the existing wording, and the FindingsPanel
source-text test pins the new button + handler wiring. Updates the
patrol-intelligence, frontend-primitives, and api-contracts contracts
to pin the contextual-intent rule and the uniform-context-attachment
invariant.
2026-05-08 19:59:21 +01:00
rcourtman 3c1138ff3c Backfill license-store mock exports in two settings test suites
useAuditWebhookPanelState and useSettingsAccess started calling
getRuntimeCapabilityBlock and isRuntimeCapabilityBlocked from
@/stores/license, but their test mocks were not updated. Vitest
threw "No 'getRuntimeCapabilityBlock' export is defined on the
'@/stores/license' mock" before any assertion could run, which
masked the actual test intent.

Add both exports to the mocks (returning undefined / false to model
the unblocked-runtime baseline). The tests pass with their original
behavioral assertions intact.
2026-05-08 19:54:14 +01:00
rcourtman bd7d196c11 Mark failed PBS poll as failure and lock down with regression tests
The PBS poller's version-failure exit at monitor_pbs_pmg.go did not
set pollErr before returning, so even after fixing the defer-arg
capture in bf6261adc the deferred recordTaskResult still saw nil and
recorded the poll as a success. PMG's analogous path already sets
pollErr correctly. Mirror that here so the per-instance pollStatusMap,
the connections aggregator, and the circuit breaker all see PBS auth
and version failures as failures.

Add assertions to the existing PBS and PMG auth-failure tests that
the per-instance pollStatusMap entry has a zero LastSuccess and a
non-zero ConsecutiveFailures. The original tests covered downstream
state but not the recorder, which is why two distinct cases of this
class of bug went unnoticed.
2026-05-08 19:53:03 +01:00
rcourtman c0153f8d41 Surface investigation confidence as a badge in the collapsed finding row
The seven-question schema's confidence answer was previously buried in
the expanded investigation section. Surface it in the collapsed row
next to the investigation outcome badge so operators can scan trust
without expanding every finding.

Adds getInvestigationConfidenceBadgeClasses helper in
aiFindingPresentation.ts with a small palette: high is reassuringly
emphasized (emerald), medium is neutral, low is a soft amber so the
operator notices when the trust signal is weak. The badge renders only
when finding.investigationRecord?.confidence is set; findings without
investigation records continue to show no confidence badge, which is
the correct semantic (we have no recorded confidence to display).

Adds a source-text test that pins the badge wiring against future
refactors. Updates the patrol-intelligence contract to pin the
collapsed-row confidence badge surface and the no-fabrication rule.
2026-05-08 19:52:09 +01:00
rcourtman c6686808f0 Fix TypeColumn.guardrails expected order to match sorted output
The two list assertions checked the .sort()-d actual array against an
unsorted hardcoded expected. Recovery.tsx (R) sorts before
Workloads/guestRowModel.tsx (W) but the literal had W first, so the
test failed deterministically. Reorder the expected entries to match
alphabetical sort.
2026-05-08 19:50:52 +01:00
rcourtman b5c8e00859 Preserve previous successful fix across regressions
Capture the prior InvestigationRecord.ProposedFix.Description into a
new Finding.PreviousResolvedFixSummary field at regression time, before
the InvestigationRecord is cleared. Without this capture the next
investigation starts from blank context whenever a finding regresses,
and operational memory of "what worked last time" is lost.

The summary propagates through:
- FindingsStore.Add regression branch (capture before clear)
- Finding.MarshalJSON / UnmarshalJSON (wire shape)
- Both Finding to UnifiedFinding conversion sites in router.go
- UnifiedFinding (struct + JSON shadow + Marshal/Unmarshal)
- UnifiedStore.AddFromAI update branch (non-empty overwrite)
- Assistant chat context as a "Previous Resolved Fix" line so the LLM
  sees what worked previously rather than blank-slate diagnosing each
  regression.

Adds a unit test that walks the full lifecycle (detect, resolve via
UpdateInvestigationRecord + ResolveWithReason, re-detect) and asserts
PreviousResolvedFixSummary is preserved while InvestigationRecord is
cleared, plus two chat-context tests covering the surfaces-when-set and
omits-when-empty cases. Adds a contract test pinning the canonical
"previous_resolved_fix_summary" JSON key. Updates the api-contracts,
ai-runtime, and the dependent agent-lifecycle, performance-and-
scalability, and storage-recovery contracts to pin the operational
memory propagation rule and its scope boundary.
2026-05-08 19:45:44 +01:00
rcourtman f530c76d17 Author Impact on AI-generated Patrol findings via tool schema
Extend the patrol_report_finding LLM tool schema with an optional
impact parameter and propagate it through PatrolFindingInput,
patrolFindingCreatorAdapter.CreateFinding, and into Finding.Impact so
LLM-authored Patrol findings carry consequence-if-ignored copy at
detection time alongside the curated catalogs already in place for
runtime failures and threshold alerts.

Updates the patrol system prompt with an "Authoring Impact" section
that instructs the LLM to write concrete operational consequences
(named workloads, jobs, recovery windows) rather than echoing severity
or category, and to leave impact empty rather than fabricate a
consequence when one is genuinely unknown. The eval-pass prompt gets a
shorter version of the same guidance.

Adds two unit tests: one covering an authored impact passing through
to PatrolFindingInput.Impact, and one covering the omitted-impact case
where the contract is honored verbatim with no synthesized default.
Updates the ai-runtime contract to pin the tool-schema authoring rule
and the no-fabrication invariant.
2026-05-08 19:28:02 +01:00
rcourtman a2c3dc77f1 Lift remediation-plan rollback into investigation records
When a Patrol finding has a generated remediation plan, the plan's
per-step Rollback strings are now aggregated into the durable
InvestigationRecord.Rollback field at record-build time. Previously
rollback metadata existed only nested inside RemediationStep.Rollback,
forcing operator-facing surfaces and Assistant prompt context to walk
into per-step payload to answer "what's the undo for the proposed fix?"

Adds AggregatePlanRollbackSteps in internal/ai/investigation_records.go
which deduplicates non-empty rollback strings from a plan's steps.
Wires the aggregation into the patrol_findings.go investigation-record
build site so rollback flows automatically when the PatrolService has a
RemediationEngine attached and the finding has an active plan.

Adds three sub-tests covering nil plan, empty-rollback-step skip, and
duplicate-rollback dedup. Updates the ai-runtime contract to pin the
RemediationPlan to InvestigationRecord rollback aggregation rule.
2026-05-08 18:17:19 +01:00
rcourtman c29cf0bbd0 Drop redundant Platform row from agent SystemInfoCard
The agent-variant card showed Platform and OS as separate rows. For
hosts where osName already carries the platform identity (Proxmox VE,
Unraid, TrueNAS), the Platform row repeated the broader OS family
("debian", "raspbian", "linux") next to the more specific osName,
implying that "debian" was the platform when the host is actually
Proxmox VE.

Merge the Platform row into the OS row. When osName is present it
carries the full identity; when it is missing the OS row falls back
to the prettified platform family. Architecture and Kernel stay on
their own rows.

Mirrors the connection-table identity fix in 1b2ac272c so both the
Settings/Infrastructure surface and the resource detail drawer agree
on the canonical platform identity.
2026-05-08 18:15:16 +01:00
rcourtman bf6261adc6 Record poll error in PVE/PBS/PMG poll-result trackers
The deferred recordTaskResult call was passing pollErr as a function
argument, so it captured the value at defer-time (always nil) instead
of the value at execution time. Result: the per-instance pollStatusMap
treated every poll as a success — LastSuccess was set to "now" on
every cycle, ConsecutiveFailures stayed at zero, and the circuit
breaker never opened, even when the staleness tracker (which used a
proper closure) recorded the same poll as a failure.

The connections aggregator derives state from PollStatus.LastSuccess,
so the Connections UI reported broken PVE/PBS/PMG instances as
"active / verified / healthy" while no data was ingested. Wrap the
recordTaskResult call in a defer closure so it reads the live pollErr
at execution time, matching the pollMetrics and stalenessTracker
defers immediately above.
2026-05-08 18:14:20 +01:00
rcourtman 1cc20d5768 Author detection-time Impact for threshold alerts and propagate through stores
Extend Impact authoring to threshold alerts: convertAlertToFinding
calls a new generateImpact(alertType) that returns hand-authored
consequence-if-ignored copy keyed on alert type (cpu, memory, disk,
storage, temperature, offline, poweredOff, plus their aliases). Unknown
alert types return an empty string rather than synthesizing generic
text, matching the contract that impact must be authored, not invented.

Fix two propagation gaps in the unified store update paths:
- AddFromAlert update branch backfills Impact on existing findings that
  pre-date the Impact contract (description and recommendation
  intentionally remain non-refreshed so the addition does not change
  historical alert wording).
- AddFromAI update branch overwrites existing.Impact when the incoming
  finding has impact set, the same pattern already used for
  description and recommendation, so re-detected AI patrol findings
  carry freshly-classified impact text into the unified store.

Adds unit tests for generateImpact (one per alert type plus a
returns-empty-for-unknown case) and for the AddFromAI Impact
propagation path. Updates the ai-runtime contract to pin the threshold
alert impact catalog and the unified-store propagation rules.
2026-05-08 18:07:37 +01:00
rcourtman 10ff1c4dcf Surface Finding.Impact through UnifiedFinding to operators
Carry the Finding.Impact text added in the previous slice through the
Finding to UnifiedFinding boundary and onto the FindingsPanel surface
so the runtime-failure consequence-if-ignored copy is visible to the
operator. Add Impact to the UnifiedFinding struct, JSON snapshot, and
both Marshal/Unmarshal mirrors; copy f.Impact into both Finding to
UnifiedFinding conversion sites in router.go; mirror impact in the TS
UnifiedFindingRecord and Finding API types and the aiIntelligence
store normalizers; render an Impact line between Description and
Recommendation in FindingsPanel.

Also fix the FindingsStore.Add dedup-merge path so re-detected findings
overwrite existing.Impact alongside Description and Recommendation
rather than preserving the stale empty value left by an older binary.
Without this fix, a freshly-classified runtime failure with new Impact
text would be merged onto the persisted finding but the Impact field
would be silently dropped.

Verified end-to-end against the live runtime: triggered a Patrol run,
watched the runtime-failure finding regenerate, confirmed the
operator-visible card now renders "Impact: While Patrol cannot
analyze..." between Description and Recommendation. Updates the
api-contracts, ai-runtime, patrol-intelligence, and the dependent
agent-lifecycle, performance-and-scalability, and storage-recovery
contracts to pin the propagation rule and the dedup-merge invariant.
2026-05-08 17:31:19 +01:00
rcourtman 1b2ac272c8 Surface platform identity for agent-only Proxmox VE hosts
Standalone hosts on the Infrastructure surface were rendering Proxmox
VE nodes as their broader OS family. Pi/delly/minipc all showed
"debian 9.1.9" instead of "Proxmox VE 9.1.9" because
connectionAgentHostProfileLabel preferred agent.platform over
agent.osName when no explicit hostProfile was reported. The agent
correctly carries the platform identity in osName but the broader
family ("debian", "raspbian") was winning the display.

Reverse the preference so osName takes priority, falling back to the
prettified platform family only when osName is absent. The canonical
badge presentation already handled this correctly for the resource
list; this change brings the connection-table summary into agreement.

Add unit coverage for the Proxmox VE, Unraid, fallback, and absent
identity cases inside ConnectionsTable.test.tsx.
2026-05-08 17:04:36 +01:00
rcourtman 744b861614 Populate Patrol runtime-failure findings with Impact
Add Impact (consequence-if-ignored) to the Finding struct so
detection-time analysis can author operator-facing impact text alongside
the existing description and recommendation, and propagate that field
into the durable aicontracts.InvestigationRecord through
BuildFindingInvestigationRecord. Wire the Patrol runtime-failure
classification path (patrolRuntimeFailureFromError) to populate a
shared impact statement covering every failure cause: while Patrol
cannot analyze, alerts continue to fire without evidence or recommended
actions, and AI Intelligence summaries cannot refresh. The text is
constant across causes because the operational consequence of a
non-running Patrol does not change with the cause; only the
recommendation does. Updates the ai-runtime contract to pin the
detection-time impact authoring rule and forbid model-side
severity/category-derived impact synthesis.
2026-05-08 16:59:00 +01:00
rcourtman 3c25674d43 Pin hybrid-source platform version precedence
Add a frontend badge presentation test covering the case where a
hybrid-source resource exposes both a platform-facet version
(proxmox.pveVersion) and an agent OS version (agent.osVersion) for
the underlying Linux distribution. The platform-facet version must
win; otherwise a Pi or Tower style host could badge a Debian or
Raspbian version next to its true platform identity.

Mirrors the backend dual-source platform identity guardrails.
2026-05-08 16:50:39 +01:00
rcourtman e7b5650233 Add impact and rollback to investigation records
Promote the seven-field investigation-record shape so Patrol findings
can carry consequence-if-ignored context and a record-level rollback
plan alongside the existing verification array. The shared
aicontracts.InvestigationRecord struct gains top-level Impact and
Rollback fields with matching TS mirrors, normalizes Rollback to an
empty slice, and the Patrol-owned investigation surface renders an
explicit "Impact not assessed" / "Rollback not specified" placeholder
so the operator-visible gap is conspicuous to both the operator and
Assistant when Patrol has not populated them. Backend default leaves
both empty rather than fabricating analysis from severity/category.
Also closes the existing Trigger.cause drift between Go and TS so
frontend handoff context preserves backend-attributed failure cause,
and updates the api-contracts, ai-runtime, frontend-primitives, and
patrol-intelligence subsystem contracts to pin the new shape.
2026-05-08 16:47:55 +01:00
rcourtman cb759e4113 Pin dual-source platform identity guardrails
Add three monitoring broadcast guardrail tests covering platform
identity for resources that have both an API/appliance facet and a
linked Pulse host agent:

- Proxmox VE node + agent (Pi case) stays on platformType=proxmox-pve
- TrueNAS appliance + agent stays on platformType=truenas
- Single-source Unraid agent host stays on platformType=agent

These pin the contract that the API/appliance facet wins over the
agent facet during platform type derivation, and that single-source
agent hosts do not promote OSName strings to platform identifiers.
2026-05-08 16:45:58 +01:00
rcourtman 407024fdc7 Filter pre-commit golangci-lint to new issues only
The previous hook scoped lint to staged packages but still surfaced
all pre-existing violations in those packages on every commit, which
turned long-standing technical debt (errcheck on test mock helpers,
dupl warnings on poll providers) into a per-commit blocker.

Add --new-from-rev=HEAD so golangci-lint only reports issues
introduced by the staged diff. Pre-existing violations on untouched
lines are filtered out; new violations on changed lines still fail
the commit. Override with GOLANGCI_LINT_NEW_FROM_REV (set to "" to
lint the whole package, or to a base revision such as HEAD~5 to
broaden the comparison window).
2026-05-08 16:45:14 +01:00
rcourtman 6c426d9f1a Scope pre-commit golangci-lint to staged packages
Whole-repo golangci-lint run ./... was OOMing the host (an in-flight
process held 18 GB resident under sustained load) and the macOS
runtime kept SIGTERM-ing it before completion, blocking commits.

Replace ./... with packages derived from staged Go files (falling
back to ./... when none) and add a 5 minute deadline overridable via
GOLANGCI_LINT_TIMEOUT. Behavior on substantial Go changes still hits
the same lint coverage; trivial-touch commits no longer scan the
whole tree.
2026-05-08 16:34:06 +01:00
rcourtman 4d5d77cf79 Deduplicate versioned infrastructure system badges 2026-05-08 15:42:18 +01:00
rcourtman 825f230dec Make Patrol recommendation prompts action-aware 2026-05-08 15:36:12 +01:00
rcourtman 797edfc6ab Preserve Patrol recommendation reasons in sessions 2026-05-08 15:33:14 +01:00
rcourtman aab3711765 Fix PVE version detection on agent hosts 2026-05-08 15:30:08 +01:00
rcourtman 3a6b07d56e Clarify Patrol Assistant briefing facts 2026-05-08 15:26:03 +01:00
rcourtman aa21e624aa Show platform versions in system badges 2026-05-08 15:07:09 +01:00