Make Patrol reasoning model-owned

This commit is contained in:
rcourtman
2026-05-15 12:22:52 +01:00
parent 895b78bd89
commit eac7dfe9ef
46 changed files with 445 additions and 4051 deletions
+69 -8
View File
@@ -2739,7 +2739,7 @@
},
{
"id": "RA28",
"summary": "Pulse Patrol remains a simple scheduled LLM probe and governance surface: Pulse supplies observed infrastructure context, evidence, policy posture, approval state, and governed execution boundaries, while the configured LLM owns diagnosis and remediation reasoning. Patrol UI and Assistant handoffs must not present Patrol-authored remediation plans, internal step lists, command summaries, or suggested prompt chips as the answer.",
"summary": "Pulse Patrol remains a simple scheduled LLM probe and governance surface: Pulse supplies observed infrastructure context, evidence, policy posture, approval state, and governed execution boundaries, while the configured LLM owns diagnosis, alert-resolution judgment, remediation reasoning, tool choice, and next-step reasoning. Patrol UI, Assistant session summaries, and Assistant handoffs must not present or persist Patrol-authored remediation plans, internal step lists, command summaries, next-step recommendations, route-owned recommendation metadata, or suggested prompt chips as the answer.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
@@ -2767,11 +2767,37 @@
"--",
"--run",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/AI/Chat/__tests__/ChatMessages.test.tsx",
"src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"src/components/patrol/__tests__/ApprovalSection.test.tsx",
"src/components/AI/__tests__/FindingsPanel.test.ts",
"src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx",
"src/utils/__tests__/patrolPagePresentation.test.ts"
"src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx",
"src/pages/__tests__/AIIntelligence.test.tsx",
"src/utils/__tests__/patrolPagePresentation.test.ts",
"src/utils/__tests__/patrolSummaryPresentation.test.ts",
"src/utils/__tests__/aiChatPresentation.test.ts",
"src/api/__tests__/aiChat.test.ts",
"src/stores/__tests__/aiChat.test.ts"
]
},
{
"id": "patrol-assistant-session-summary-tests",
"run": [
"go",
"test",
"./internal/ai/chat",
"-run",
"TestSessionStore_List|TestService_ExecuteStream_AttachesModelOnlyHandoffContext"
]
},
{
"id": "patrol-model-owned-remediation-runtime-tests",
"run": [
"go",
"test",
"./internal/ai",
"-run",
"TestPatrolService_RecordFindingDoesNotGenerateTemplateRemediationPlan|TestShouldResolveAlert|TestReviewAndResolveAlerts"
]
}
],
@@ -2801,11 +2827,6 @@
"path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/__tests__/FindingsPanel.capacityForecast.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts",
@@ -2816,6 +2837,11 @@
"path": "frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/index.tsx",
@@ -2831,6 +2857,11 @@
"path": "frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
@@ -2841,15 +2872,45 @@
"path": "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/aiChatPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/patrolPagePresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/session.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/types.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_findings.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_run.go",
"kind": "file"
}
]
},
@@ -512,12 +512,11 @@ runtime cost control, and shared AI transport surfaces.
handoffs. That summary may include the handoff kind, finding ID, resource
and Patrol run ID, safe run type/status/runtime-failure flags, resource and
action counts, a primary resource label, last-known approval/action status,
risk level, timestamp, and Patrol recommended next-step title/detail/action
labels plus the safe recommendation action kind or whitelisted app-route href only when
they can be safely extracted from the stored Patrol handoff, but it must not
expose model-only handoff text,
runtime failure detail, action preflight/result bodies, remediation
descriptions, raw commands, or approval command payloads. Its
risk level, and timestamp, but it must not expose Patrol-authored
recommended next-step titles, recommendation details, route-owned action
labels, model-only handoff text, runtime failure detail, action
preflight/result bodies, remediation descriptions, raw commands, or
approval command payloads. Its
`requires_approval` field is a current operator-decision flag only: pending
approval states may set it, but approved, denied, rejected, executing,
completed, failed, expired, or otherwise historical action references must
@@ -527,8 +526,8 @@ runtime cost control, and shared AI transport surfaces.
safe visible briefing: the next chat turn must carry
`autonomous_mode:false` even when the summary is context-only and has no
queued action, while the visible badge/action copy must still reflect the
actual last-known action state or Patrol assessment recommendation instead
of inventing a pending approval. That
actual last-known action state or Patrol assessment context instead of
inventing a pending approval or restoring a Patrol recommendation. That
restoration is success-bound: if the underlying session message load fails,
the drawer must leave the current context untouched instead of applying
summary-derived Patrol or approval state for a session the operator is not
@@ -542,20 +541,17 @@ runtime cost control, and shared AI transport surfaces.
The Assistant drawer must also fetch that current session list before
opening the session picker instead of presenting mount-time cached
summaries as the operator's decision surface. For restored Patrol
assessment or finding sessions, that picker must present the safe
recommended next-step title/detail/action label from `handoff_summary` when
one is available and restore the safe recommendation detail, action kind,
or route-owned href as context metadata instead of reducing the saved
session to generic context.
Live Patrol assessment handoffs that include a currently unavailable
Patrol-owned recommendation action must carry the bounded disabled reason in
the model-only handoff and visible briefing so Assistant explains the
current availability state instead of treating the action as executable.
assessment or finding sessions, that picker must present only the safe
handoff kind, source, resource/action counts, and approval/action status
from `handoff_summary`; it must not restore Patrol recommended
next-step title/detail/action labels, route-owned hrefs, or disabled-action
reasons as visible or hidden context.
Browser-originated `handoff_context`, `handoff_resources`, and
`handoff_actions` plus safe `handoff_metadata` are one-shot request seeds
for the first successful chat turn. Safe Patrol next-step titles, details,
labels, and route-owned hrefs belong in `handoff_metadata` first, with model-context
text parsing only as a legacy fallback. After that send succeeds, the drawer
for the first successful chat turn. Patrol next-step titles, details,
labels, and route-owned hrefs do not belong in `handoff_metadata`, and
model-context text parsing must not resurrect them as a legacy fallback.
After that send succeeds, the drawer
must clear those request payloads while preserving the safe visible
briefing and request-local
approval-required posture; later turns must rely on backend-owned session
@@ -1528,9 +1524,9 @@ derived from the current unified finding and structured Patrol investigation
record before the lower-level finding context. That briefing must summarize the
finding, resource, priority, current attention reason, current recency facts,
bounded evidence and verification summaries, investigation confidence,
recommended next step, operator decision framing, latest lifecycle event, and
governed action posture as operator guidance, while leaving detailed lifecycle
history, current resource-state, timeline, related-finding, and action-audit
operator decision framing, latest lifecycle event, and governed action posture
as model context, while leaving detailed lifecycle history, current
resource-state, timeline, related-finding, and action-audit
hydration in the existing canonical AI runtime handoff builders. Related
root-cause and
correlated finding records may be summarized from current unified finding state,
@@ -380,10 +380,9 @@ the canonical monitored-system blocked payload.
`internal/api/ai_handler.go`, `internal/api/ai_handler_test.go`, and
`internal/api/contract_test.go` together. Patrol-originated handoffs must
keep `[Operator Briefing]`, `[Finding Context]`, `[Finding Lifecycle
Context]`, structured handoff resources, safe route-owned next-step action
labels/hrefs, related root-cause/correlation finding context, and structured
handoff actions model-only, with the briefing summarizing operator next
steps, latest lifecycle event, and governed action posture without raw
Context]`, structured handoff resources, related root-cause/correlation
finding context, and structured handoff actions model-only, with the
briefing summarizing latest lifecycle event and governed action posture without raw
command text. Structured handoff action
references may use the current live Patrol investigation-fix approval for
the finding when that approval is newer than the approval ID on the durable
@@ -399,13 +398,12 @@ the canonical monitored-system blocked payload.
user's persistent autonomous control setting; live approval, proposed-fix,
fix-outcome, and remediation-plan references only add structured action
posture, they are not the trigger for the boundary. Frontend-visible Patrol
briefing payloads may include short suggested prompts, but those prompts
must be derived from the same safe action posture, evidence, recurrence, and
remediation-plan metadata as the briefing and must never carry raw approval,
command, or rollback command text. Frontend queued-fix recovery handoffs
briefing payloads must stay compact and must not include suggested prompt
chips, Patrol-authored next-step recommendations, or route-owned
recommendation metadata. Frontend queued-fix recovery handoffs
where the live approval or proposed-fix payload is unavailable must still
carry that Patrol-owned operator briefing, current `fix_queued` posture,
request-local approval-required mode, and safe suggested prompts; they must
request-local approval-required mode, and model-only evidence context; they must
not degrade into generic Assistant investigation chat or imply that
execution can proceed from missing command payloads. Expired-approval
recovery handoffs may use a still-available structured proposed-fix payload
@@ -562,32 +560,29 @@ the canonical monitored-system blocked payload.
and that same Patrol investigation-context owner, so the current Patrol
assessment summary may open Assistant with bounded model-only assessment,
verification, latest-run, supporting-context evidence, active-finding, and
resource reference context plus safe source-owned suggested prompts and the
Patrol-owned recommended next step as safe metadata; active
resource reference context while leaving prioritization and next-step
reasoning to the configured LLM; active
finding entries may carry live pending Patrol approval posture only as safe
structured handoff actions with approval ID/status/risk/target/request/expiry
metadata, action plan identity/policy/expiry, dry-run posture, and command
counts instead of pasting page-local UI text or raw command payloads into chat,
the drawer target must stay `patrol-assessment` rather than a retired
dashboard target,
and may derive visible action labels, safety notes, and approval-aware
suggested prompts plus first-turn prompt emphasis from that same safe metadata
plus the current disabled reason for a Patrol-owned recommended action when
the visible summary action is unavailable,
so the drawer does not fall back to generic assessment copy when governed
actions are present or when the summary already exposes a concrete
recommended next step
and may derive model-only approval/action posture from that same safe
metadata, while visible drawer copy stays compact and does not expose
Patrol-authored recommendations, prompt chips, or action labels as the
answer,
and that same Patrol investigation-context owner, so coverage signals from
the canonical AI summary remain secondary caveats when the same assessment
carries active findings, pending approvals, or governed action references;
Assistant prompt emphasis, briefing action labels, and safety notes must
lead with finding priority, affected resources, evidence, and the governed
next step instead of recasting the whole handoff as a coverage gap
carry finding priority, affected resources, evidence, and governed approval
posture as context instead of recasting the whole handoff as a coverage gap
and that same Patrol investigation-context owner, so coverage-incomplete
assessments with no active infrastructure findings are serialized as a
verification-gap handoff: prompt and visible briefing copy must explain what
scoped activity did and did not prove, keep latest-run and supporting-context
facts model-only, carry the existing summary recommendation as safe
facts model-only, and leave next-step selection to the configured LLM
handoff-only metadata, and avoid introducing backend fields beyond the
existing Patrol status plus run-history contracts
and that same Patrol investigation-context owner, so visible Assistant
@@ -2609,14 +2604,10 @@ references used to seed canonical approval and action-audit refresh.
`handoff_metadata` is the browser-safe identity envelope for restoring saved
product handoffs, currently including Patrol run kind, run ID, safe run
type/status, a runtime-failure boolean rather than runtime failure detail, and
safe Patrol recommended next-step title/detail/action labels plus whitelisted app-route
hrefs when the browser handoff already owns that route. Frontend-visible
Patrol assessment briefings may render those same safe recommendation fields as
separate title, reason, and route-action facts, but they must not introduce new
browser-owned authority beyond the structured handoff metadata and bounded
chat context. Browser-visible suggested prompts may follow the structured
recommendation action, such as provider-settings recovery for runtime
visibility failures, while remaining review prompts rather than API authority.
bounded resource/action counts when available. Frontend-visible Patrol
assessment briefings must not render recommendation fields as separate title,
reason, route-action facts, or prompt chips; the configured model owns those
decisions from the structured handoff metadata and bounded chat context.
Frontend handoff builders may send these fields for owned alert, incident,
Patrol assessment, Patrol finding, or Patrol run-history context, but the
backend must not
@@ -2631,13 +2622,11 @@ The `/api/ai/sessions` response may expose `handoff_summary` for sessions that
carry private Assistant model-context metadata, but that payload is a safe
reload marker only. It may carry the handoff kind, finding ID, Patrol run ID,
safe run type/status/runtime-failure flags, counts, primary-resource label,
last-known approval/action status, risk level, Patrol recommended
next-step title/detail/action labels plus the safe recommendation action kind or whitelisted
app-route href when safely extractable from the stored Patrol handoff, and
summary timestamp; it must
last-known approval/action status, risk level, and summary timestamp; it must
not serialize the model-only `handoff_context`, runtime failure detail, action
preflight/result bodies, remediation descriptions, raw commands, or approval
command payloads.
command payloads, and it must not preserve Patrol-authored next-step
recommendation fields from legacy handoffs.
Patrol finding handoffs are stricter than ordinary chat requests: when a request
carries a non-empty `finding_id` or resolves to model-only Patrol briefing,
resource, or action context, `internal/api/ai_handler.go` must clamp the
@@ -186,11 +186,9 @@ work extends shared components instead of creating new local variants.
`handoffContext`, `handoffResources`, `handoffActions`, and safe
`handoffMetadata` while preserving the safe visible briefing and scoped
approval-required posture, so later turns rely on backend session hydration
instead of resending stale browser context. When a Patrol handoff includes a
safe next-step label, action kind, or whitelisted app-route href, those
reload-safe fields must travel through `handoffMetadata` rather than relying
on the browser to reconstruct them from model-only handoff text. The drawer
must treat
instead of resending stale browser context. Patrol handoffs must not include
safe next-step labels, action kinds, or whitelisted app-route hrefs in
`handoffMetadata`; the drawer must treat
`handoff_summary.requires_approval` as a current pending-decision flag, not a
historical action marker, so completed or rejected handoff actions render as
action context rather than pending approval. A restored Patrol run summary
@@ -204,16 +202,16 @@ work extends shared components instead of creating new local variants.
can rebuild model-bound context from the stored Patrol run. Restored
Patrol assessment, Patrol finding, and Patrol configuration-failure sessions
follow the same safe-summary rule: the drawer may restore source label,
title, target type, status badge, action label, and suggested prompts from
`handoff_summary`, but
title, target type, status badge, bounded resource facts, and approval/action
status from `handoff_summary`, but
it must not infer a finding target from bounded action references or
reconstruct hidden model context, provider details, retry payloads, commands,
preflight output, or action results in the browser. If the safe summary
includes a Patrol recommended next step, recommendation detail, action
label, safe action kind, or whitelisted app-route href, the session picker
plus restored drawer briefing and action copy must use that recommendation
and may carry the safe detail, action kind, or href as context metadata
rather than falling back to generic handoff copy.
was created by a legacy build that stored Patrol next-step metadata,
recommendation detail, action labels, safe action kind, or whitelisted
app-route href, the session picker plus restored drawer must ignore those
fields rather than carrying them forward as hidden context or visible
recommendation copy.
Session-load and new-conversation transitions must be success-bound: if the
underlying session operation fails, the shared drawer store must not clear or
replace the current scoped handoff context.
@@ -237,17 +235,14 @@ surface, while paid-plan copy and compatibility feature probes stay out of the
frontend primitive boundary.
Feature surfaces under `frontend-modern/src/features/` may own product-specific
assessment and recommendation semantics, but they must keep those semantics in
their governed presentation helpers and render them inside the shared neutral
Pulse surface language rather than introducing page-local verdict bands or
nested cards. Patrol's primary recommended next step follows this boundary:
the Patrol helper owns the decision text, while the section component only
renders the compact default next-step title and one bounded action inside the
existing assessment shell, with detail copy deferred to the section's details
expansion. Feature-owned recommendation actions may use the shared
button/link shape, but they must not create a second full-width action band or
nest another card inside the primary Pulse surface. If the same assessment
opens Assistant, the Patrol-to-Assistant handoff must carry that exact
assessment semantics, but they must keep those semantics in their governed
presentation helpers and render them inside the shared neutral Pulse surface
language rather than introducing page-local verdict bands or nested cards.
Patrol's primary assessment strip is descriptive only; it must not render a
Patrol-authored recommended next step, suggested prompt chips, or a secondary
action band inside the assessment shell. If the same assessment opens
Assistant, the Patrol-to-Assistant handoff must carry only bounded evidence,
resource references, and governed approval/action posture as model-only context.
recommendation as safe bounded metadata so the drawer briefing and first-turn
prompt explain the same operator-facing priority. Feature-owned Assistant
handoff prompts may provide source context and safe metadata, but the shared
@@ -1055,33 +1050,24 @@ reasoning after the request reaches the AI runtime.
`handoff_actions` for
model-only refresh, but the shared drawer stays a generic shell rather than
a Patrol summary prompt builder. The Patrol helper may turn those same safe
references into visible action labels, safety notes, and approval-aware
suggested prompts or first-turn prompt emphasis for assessment and
finding-level handoffs; if a source-owned Patrol recommendation action is
currently disabled, the helper must pass the bounded disabled reason in
visible briefing and model-only context instead of presenting that action as
available. Assessment recommendation briefings must keep the safe
recommendation title, reason, and route-owned action as separate visible
drawer facts so the shared Assistant shell presents operator guidance
without parsing a compressed context sentence. The same feature helper owns
recommendation-aware suggested prompts; shared drawer primitives must render
those prompts without replacing a provider-settings recovery path with
generic coverage wording. Assessment-level Patrol prompts, action labels,
and safety notes must also prioritize active findings, pending approvals,
and governed action references over secondary coverage caveats, reserving
coverage-gap-first wording for assessments with no active findings.
references into visible action labels and safety notes for assessment and
finding-level handoffs, but it must not produce Patrol-authored suggested
prompt chips, recommendation titles, recommendation reasons, or route-owned
next-step actions. Assessment-level Patrol prompts, action labels, and
safety notes must describe active findings, pending approvals, governed
action references, and coverage caveats as evidence for the configured
model, not as a frontend-authored decision tree.
Finding-level drawer opens may also pass one bounded
model-only finding context, one target resource reference, one safe
route-owned next-step action label/href, and one `handoff_actions` reference
for a live approval or proposed fix. It must not expose raw command or
execution payloads.
model-only finding context, one target resource reference, and one
`handoff_actions` reference for a live approval or proposed fix. It must not
expose raw command or execution payloads.
The
drawer may render a generic
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
feature-owned helpers must provide compact source labels, primary subject,
status, and any safe route action while keeping attention reason, evidence
summaries, operator-decision copy, safety notes, and prompt suggestions in
model-only or governed action context unless the operator asks for them.
status, and governed approval/action posture while keeping attention reason,
evidence summaries, operator-decision copy, and safety notes compact and
source-owned. Prompt suggestions must stay out of Patrol drawer chrome.
Patrol finding and action-artifact handoffs must not render suggested prompt
chips in the drawer and must not become another primitive path for raw
approval, command, or rollback command payload text. Missing-detail
@@ -1686,8 +1672,7 @@ repeat assessment or verification labels as a second compact verdict row.
The collapsed Patrol assessment strip itself must remain a compact readout
rather than a headline-plus-paragraph block; explanatory assessment and
recommendation copy belongs in the owning Findings, Runs, Supporting context,
or recommended-action surfaces rather than a normal-path summary details
expansion.
or Assistant chat surfaces rather than a normal-path summary details expansion.
That readout should lead with current operator state and score rather than
mixing a reassuring grade label with issue-state copy in the same line.
That same summary shell should also keep the shared Pulse surface neutral:
@@ -94,8 +94,8 @@ Patrol-specific presentation helpers.
Patrol assessment copy must not present an all-clear health prediction while
active Patrol findings or Patrol runtime issues are still present. The
canonical summary helper owns that conflict resolution so the visible
assessment title, description, metrics, and recommended next step all speak
from the same current findings state. Patrol-owned runtime issues must stay
assessment title, description, and compact metrics all speak from the same
current findings state. Patrol-owned runtime issues must stay
distinct from infrastructure findings in assessment copy rather than being
described as infrastructure warning findings about Patrol itself.
Assessment coverage caveats must also reconcile against current run-history
@@ -175,11 +175,11 @@ Patrol-specific presentation helpers.
than approval, lifecycle, disclosure, or execution authority.
Assistant handoffs from Patrol findings must also include a concise operator
briefing derived from the unified finding and structured investigation record
before the detailed finding context, so Assistant leads with the current risk,
before the detailed finding context, so Assistant receives current risk,
attention reason, recency, evidence snapshot, verification summary,
conclusion, latest lifecycle event, recommended next step, explicit operator
decision framing, and governed approval/action posture instead of behaving
like a generic chat over a pasted incident dump. Patrol is the scheduled
conclusion, latest lifecycle event, explicit operator decision framing, and
governed approval/action posture instead of behaving like a generic chat over
a pasted incident dump. Patrol is the scheduled
probe, context assembler, and execution-governance owner; the configured LLM
is the diagnostic and remediation-reasoning owner. Patrol handoff prompts may
provide system context, resource posture, action posture, and governed tools,
@@ -189,8 +189,9 @@ Patrol-specific presentation helpers.
produced the correct fix. The visible Assistant drawer briefing opened from a Patrol
finding must be compact and source-named: current status/risk, one primary
subject, and any approval-required boundary, with richer evidence, action
artifacts, command counts, and prompt suggestions staying in model-only or
governed action context rather than drawer chrome. When a
artifacts, and command counts staying in model-only or governed action
context rather than drawer chrome; prompt suggestions must not be generated
for Patrol handoffs. When a
structured investigation record is not available yet, the same Patrol-owned
helper must still brief the operator from current finding facts such as
active status, severity, recurrence, and loop state instead of opening a
@@ -207,12 +208,12 @@ Patrol-specific presentation helpers.
answer. Finding
handoffs must be assembled through the Patrol-owned handoff model so the
prompt, visible briefing, model-only finding context, resource reference,
safe next-step action label/href, bounded action reference, and request-local
approval-required posture stay in sync. The model-only context may include
bounded action reference, and request-local approval-required posture stay in
sync. The model-only context may include
current finding status, recurrence, investigation record facts, evidence,
verification, approval posture, dry-run posture, proposed-fix summary, target
resource references, and safe route-owned next-step labels/hrefs such as
provider settings without raw command payloads. Inline Patrol approval actions in
resource references, and governed action references without raw command
payloads. Inline Patrol approval actions in
`frontend-modern/src/components/patrol/ApprovalSection.tsx` that open
Assistant must follow that same Patrol-owned handoff model rather than a
prompt-only local shortcut: pass approval ID/status/risk/target plus safe
@@ -315,7 +316,7 @@ Patrol-specific presentation helpers.
handoff, and a parallel "Explain" button opens the same handoff with
a `PatrolAssistantFindingIntent='explain'` seed that asks the LLM to
walk through what we know, why it matters, how confident the
analysis is, and whether the recommended action is appropriate. Both
analysis is, what remains uncertain, and what the model would do next. Both
buttons must route through `buildPatrolAssistantFindingHandoff` so
the structured context (investigation record, operational memory,
pending approval, proposed fix, next-step action) is attached
@@ -354,13 +355,7 @@ Patrol-specific presentation helpers.
verification recency, latest run, secondary investigation context, bounded
recent-change and learned-correlation evidence, active-finding summaries,
structured resource references, structured approval/action references, and
safe source-owned suggested prompts as model-only context. The same handoff
must also carry the Patrol-owned recommended next step as safe bounded
metadata, including its title, detail copy, action label, and known action
kind when present, plus the current action-disabled reason when the visible
Patrol-owned action is unavailable, so Assistant explains the same
operator-facing priority and current availability shown in the summary card
instead of inventing a separate next step. Assessment-level handoffs must
model-only context. Assessment-level handoffs must
identify the drawer target as
`patrol-assessment` with `targetId=pulse-patrol-assessment`, matching saved
session restore semantics rather than the retired dashboard target.
@@ -372,30 +367,26 @@ Patrol-specific presentation helpers.
command and approval payloads out of prompt and drawer copy, surface visible
drawer action posture from the same safe references, make the initial prompt
lead with approval/action review when governed references are attached, and
frame Assistant as explanation, prioritization, and safe next-step review
frame Assistant as explanation, prioritization, and model-owned next-step reasoning
rather than a generic reactive chat
box. That whole-surface assessment handoff must send safe
`handoff_metadata.kind=patrol_assessment` so saved Assistant sessions restore
as current-assessment context instead of becoming generic scoped context or
an accidental single-finding session because one bounded action reference
names a finding. Saved assessment and finding sessions may expose the
Patrol-owned recommended next step title/detail/action and whitelisted
app-route href through the safe `handoff_summary` only after command-like
and secret-like text is withheld; live handoffs must send those safe fields
through structured `handoff_metadata` where available, and the browser must
use them for restored drawer copy without receiving the private model-only
handoff context. Assessment Assistant drawer briefings must present the safe
recommended step title, reason, and route-owned action as separate operator
facts instead of compressing them into an opaque context sentence, and the
suggested prompts must follow the structured recommendation action so
provider-setting/runtime-visibility failures lead with provider checks and
post-restore verification rather than generic coverage questions.
names a finding. Saved assessment and finding sessions must not expose or
restore Patrol-authored next-step titles, recommendation detail, action
labels, or app-route hrefs through `handoff_summary`; legacy stored
recommendation fields are ignored rather than converted into hidden context.
Assessment Assistant drawer briefings must stay compact and source-named
instead of presenting a recommended step title, reason, route action, or
suggested prompt chips as operator-facing answers, so
provider-setting/runtime-visibility failures remain plain context for the
configured model rather than Pulse-authored recovery guidance.
When the current Patrol assessment is coverage-incomplete with no active
infrastructure finding, the same handoff model must frame the briefing as a
verification gap: the prompt leads with what scoped activity did and did not
prove, visible drawer copy names the coverage gap, suggested prompts focus
on full-run verification and early warning signals, and execution or retry
remains operator-controlled.
infrastructure finding, the same handoff model may describe incomplete
coverage as evidence, but it must let the configured model decide whether
more verification, operator action, or governed tool use is needed. Execution
or retry remains operator-controlled.
Patrol run-history entries may also open Assistant for a selected run, but
that handoff must flow through the same Patrol-owned investigation-context
model rather than a row-local prompt. The browser-visible prompt and drawer
@@ -416,13 +407,11 @@ Patrol-specific presentation helpers.
than a hero-style or decorative card surface. The default collapsed state is
a compact readout, not a headline block: show the Patrol assessment label,
current operator state, concise high-signal trust posture such as
regressions when present, score, one recommended next-step title, and one
route-owned action. Do not combine reassuring grade labels such as
regressions when present, and score. Do not combine reassuring grade labels such as
`Health A` with issue-state copy such as `Issues detected` in the collapsed
line. Do not add a normal-path assessment details expansion: assessment
explanation, recommendation detail, verification detail, activity mix,
supporting metrics, and whole-assessment Assistant discussion belong in the
owning Findings, Runs, Supporting context, or recommended-action surfaces
explanation, verification detail, activity mix, and supporting metrics belong in the
owning Findings, Runs, or Supporting context surfaces
instead of reopening the compact strip into a sparse status panel.
## Current State
@@ -476,29 +465,16 @@ render contract: the header chip, primary summary card, and status bar must
all route through the shared `frontend-modern/src/utils/patrolRuntimePresentation.ts`
helper plus the backend `runtime_state` payload instead of inferring operator
state from the last healthy summary snapshot or run history alone.
The primary summary card now also has a Patrol-owned Assistant assessment
handoff. `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx`
opens Assistant through
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`, which
packages the current Patrol assessment, verification posture, latest run,
The primary summary strip is descriptive only: it surfaces the current Patrol
assessment label and compact counts, while action choices remain in the
Findings/Runs workspace, header controls, and the LLM-driven Assistant chat.
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` may
package the current Patrol assessment, verification posture, latest run,
secondary investigation context, bounded recent-change and learned-correlation
evidence, bounded active-finding summaries, source-owned suggested prompts, and
the Patrol-owned recommended next step, plus deduped resource references and
safe structured approval/action references as
model-only context while forcing `autonomousMode:false` and summarizing
proposed-fix command-bearing records and command-bearing change events without
raw command text. Its visible Assistant briefing must also use those safe
references to distinguish pending governed approvals or attached action
references from a generic assessment discussion, including approval-policy and
dry-run posture when available. When no approval or governed action outranks
the summary recommendation, the briefing action label and initial prompt may
lead with that recommendation, but Assistant remains explanatory and may not
start Patrol runs, settings changes, diagnostics, remediation, or approvals
from the handoff; if the recommended action is currently disabled, the prompt
and briefing must say why instead of describing it as an available action. Its
initial prompt must prioritize approvals or action
references before broader assessment discussion while command payloads stay
out of the drawer.
evidence, bounded active-finding summaries, deduped resource references, and
safe structured approval/action references as model-only context, but visible
drawer copy must not promote Pulse-authored next-step metadata, action chips, or
suggested prompts as the answer. Command payloads stay out of drawer chrome.
Run-history rows now follow that same Assistant handoff model. A selected
`frontend-modern/src/components/patrol/RunHistoryEntry.tsx` row may open
Assistant through
@@ -474,8 +474,9 @@ IDs, action IDs, policy, expiry, dry-run posture, and proposed-fix labels; they
must not expose raw command or execution payloads or become an approval bypass.
Assistant operator briefings generated from Patrol findings follow the same
boundary: they may summarize approval IDs, proposed-fix IDs, risk, destructive
posture, and recommended next steps for review, but they must not expose raw
command payloads or convert chat into approval or execution authority.
posture, and bounded evidence for model review, but they must not expose raw
command payloads, present Patrol-authored remediation guidance, or convert chat
into approval or execution authority.
Action planning and action decision mutations remain privileged runtime
control surfaces even though the decision endpoint does not execute the
capability. `POST /api/actions/plan` and
@@ -594,9 +594,9 @@ bypass the API fail-closed execution gate.
recovery freshness, restore eligibility, or storage-local execution state.
Operator briefings generated for those same Assistant finding handoffs are
also adjacent AI/runtime context only: storage and recovery surfaces may read
their Patrol conclusion or recommended next step as investigation guidance,
but must not reinterpret the briefing as backup recency, restore support, or
storage-local action authority.
their Patrol conclusion and bounded evidence as investigation context, but
must not reinterpret the briefing as backup recency, restore support,
Patrol-authored remediation guidance, or storage-local action authority.
That same adjacent `internal/api/` boundary still carries Patrol-run
execution identity. Storage and recovery may observe shared Patrol
transport through `internal/api/chat_service_adapter.go`, but they must not
+2 -2
View File
@@ -188,7 +188,7 @@ describe('AIAPI', () => {
description:
'Pulse Patrol could not maintain a healthy connection to the configured provider during analysis.',
impact:
'While Patrol cannot analyze, alerts continue to fire without evidence or recommended actions, and AI Intelligence summaries cannot refresh.',
'While Patrol cannot analyze, alerts continue to fire without fresh Patrol evidence, and AI Intelligence summaries cannot refresh.',
recommendation:
'Check provider reachability, base URL, firewall or proxy rules, and provider availability, then rerun Patrol.',
detected_at: '2026-05-08T12:00:00Z',
@@ -200,7 +200,7 @@ describe('AIAPI', () => {
const result = await AIAPI.getUnifiedFindings();
expect(result.findings[0].impact).toBe(
'While Patrol cannot analyze, alerts continue to fire without evidence or recommended actions, and AI Intelligence summaries cannot refresh.',
'While Patrol cannot analyze, alerts continue to fire without fresh Patrol evidence, and AI Intelligence summaries cannot refresh.',
);
});
@@ -50,10 +50,6 @@ describe('AIChatAPI', () => {
last_known_approval_status: 'pending',
last_known_action_state: 'awaiting_approval',
last_known_action_risk: 'high',
recommended_next_step: 'Review pending Patrol approval',
recommended_next_step_action: 'Review approvals',
recommended_next_step_action_kind: 'review_approvals',
recommended_next_step_action_href: '/patrol',
updated_at: '2026-05-06T12:08:00Z',
},
};
@@ -225,10 +221,6 @@ describe('AIChatAPI', () => {
undefined,
{
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepDetail: 'Fix the Patrol runtime issue before continuing.',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
},
);
@@ -243,10 +235,6 @@ describe('AIChatAPI', () => {
autonomous_mode: false,
handoff_metadata: {
kind: 'patrol_finding',
recommended_next_step: 'Open Patrol provider settings',
recommended_next_step_detail: 'Fix the Patrol runtime issue before continuing.',
recommended_next_step_action: 'Open Patrol provider settings',
recommended_next_step_action_href: '/settings/system-ai',
},
}),
}),
+8 -10
View File
@@ -494,17 +494,15 @@ export interface RemediationPlan {
risk_level: 'low' | 'medium' | 'high';
status: 'pending' | 'approved' | 'executing' | 'completed' | 'failed' | 'rolled_back';
created_at: string;
// Optional deterministic action proposal attached by the patrol pipeline
// (currently only forecast-driven capacity proposals - see internal/ai/forecast).
// RequiresApproval is invariant on every proposal; Allowed=false signals
// a preflight-only proposal (no write capability wired yet).
// Optional governed action proposal projection. Patrol finding creation
// must not populate this from category/title heuristics.
// RequiresApproval is invariant on every proposal; Allowed=false signals a
// preflight-only proposal (no write capability wired yet).
proposed_action_plan?: ProposedActionPlan;
}
// ProposedActionPlan mirrors pkg/aicontracts.ProposedActionPlan on the
// wire. The "capacity_forecast" Source value tells FindingsPanel to render
// the distinguishable forecast approval card; anything else falls back to
// the generic remediation plan card.
// wire.
export interface ProposedActionPlan {
actionId: string;
capabilityName?: string;
@@ -512,15 +510,15 @@ export interface ProposedActionPlan {
requiresApproval: boolean;
approvalPolicy?: string;
message?: string;
source?: 'capacity_forecast' | string;
source?: string;
projectedMetric?: ProposedMetricSummary;
preflight?: ProposedActionPreflight;
plannedAt?: string;
expiresAt?: string;
}
// ProposedMetricSummary is the operator-facing snapshot rendered at the
// top of the capacity-forecast approval card.
// ProposedMetricSummary is the operator-facing metric snapshot for a
// governed action proposal.
export interface ProposedMetricSummary {
metric: string;
currentValue: number;
-15
View File
@@ -53,11 +53,6 @@ export interface ChatSessionHandoffSummary {
last_known_approval_status?: string;
last_known_action_state?: string;
last_known_action_risk?: string;
recommended_next_step?: string;
recommended_next_step_detail?: string;
recommended_next_step_action?: string;
recommended_next_step_action_kind?: string;
recommended_next_step_action_href?: string;
updated_at?: string;
}
@@ -67,11 +62,6 @@ export interface ChatHandoffMetadata {
runType?: string;
runStatus?: string;
runtimeFailure?: boolean;
recommendedNextStep?: string;
recommendedNextStepDetail?: string;
recommendedNextStepAction?: string;
recommendedNextStepActionKind?: string;
recommendedNextStepActionHref?: string;
}
export interface ChatHandoffAction {
@@ -320,11 +310,6 @@ export class AIChatAPI {
run_type: handoffMetadata.runType,
run_status: handoffMetadata.runStatus,
runtime_failure: handoffMetadata.runtimeFailure,
recommended_next_step: handoffMetadata.recommendedNextStep,
recommended_next_step_detail: handoffMetadata.recommendedNextStepDetail,
recommended_next_step_action: handoffMetadata.recommendedNextStepAction,
recommended_next_step_action_kind: handoffMetadata.recommendedNextStepActionKind,
recommended_next_step_action_href: handoffMetadata.recommendedNextStepActionHref,
};
}
@@ -985,11 +985,6 @@ describe('AIChat', () => {
resource_count: 2,
action_count: 0,
requires_approval: false,
recommended_next_step: 'Verify full coverage',
recommended_next_step_detail:
'Run a full Patrol sweep before treating the assessment as current.',
recommended_next_step_action: 'Run Patrol',
recommended_next_step_action_kind: 'run_patrol',
},
},
]);
@@ -1003,7 +998,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
expect(screen.getByText('Assessment context')).toBeInTheDocument();
expect(screen.getByText('Recommended: Run Patrol')).toBeInTheDocument();
expect(screen.queryByText('Recommended: Run Patrol')).not.toBeInTheDocument();
});
fireEvent.click(screen.getByText('Assessment follow-up'));
@@ -1018,22 +1013,13 @@ describe('AIChat', () => {
context: expect.objectContaining({
kind: 'patrol_assessment',
findingId: undefined,
recommendedNextStep: 'Verify full coverage',
recommendedNextStepDetail:
'Run a full Patrol sweep before treating the assessment as current.',
recommendedNextStepAction: 'Run Patrol',
recommendedNextStepActionKind: 'run_patrol',
}),
briefing: expect.objectContaining({
sourceLabel: 'Pulse Patrol',
title: 'Patrol assessment handoff',
subject: 'Current Patrol assessment',
actionLabel: 'Recommended: Run Patrol',
detailLines: expect.arrayContaining([
'Recommended next step: Verify full coverage',
'Reason: Run a full Patrol sweep before treating the assessment as current.',
'Available action: Run Patrol',
]),
actionLabel: 'Review Patrol assessment',
detailLines: ['2 linked resources'],
}),
}),
);
@@ -1183,9 +1169,6 @@ describe('AIChat', () => {
},
action_count: 0,
requires_approval: false,
recommended_next_step: 'Open Patrol provider settings',
recommended_next_step_action: 'Open Patrol provider settings',
recommended_next_step_action_href: '/settings/system-ai',
},
},
]);
@@ -1199,7 +1182,7 @@ describe('AIChat', () => {
await waitFor(() => {
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
expect(screen.getByText('Context attached')).toBeInTheDocument();
expect(screen.getByText('Open Patrol provider settings')).toBeInTheDocument();
expect(screen.queryByText('Open Patrol provider settings')).not.toBeInTheDocument();
});
fireEvent.click(screen.getByText('Context-only Patrol follow-up'));
@@ -1213,15 +1196,11 @@ describe('AIChat', () => {
context: expect.objectContaining({
actionCount: 0,
requiresApproval: false,
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
}),
briefing: expect.objectContaining({
sourceLabel: 'Pulse Patrol',
title: 'Patrol finding on web-server',
actionLabel: 'Open Patrol provider settings',
actionHref: '/settings/system-ai',
actionLabel: undefined,
}),
}),
);
@@ -145,33 +145,6 @@ const getSessionHandoffBadgeLabel = (summary: ChatSessionHandoffSummary) => {
return summary.has_model_context ? 'Context attached' : 'Scoped handoff';
};
const formatSessionHandoffRecommendationLabel = (summary: ChatSessionHandoffSummary) => {
if (!isPatrolSessionHandoff(summary)) return '';
const action = summary.recommended_next_step_action?.trim();
if (action) return isPatrolFindingSessionHandoff(summary) ? action : `Recommended: ${action}`;
const nextStep = summary.recommended_next_step?.trim();
return nextStep ? `Recommended: ${nextStep}` : '';
};
const getSessionHandoffRecommendedActionHref = (
summary: ChatSessionHandoffSummary,
): string | undefined => {
const summaryHref = summary.recommended_next_step_action_href?.trim();
if (summaryHref === '/settings/system-ai' || summaryHref === '/patrol') {
return summaryHref;
}
switch (summary.recommended_next_step_action_kind?.trim()) {
case 'open_provider_settings':
return '/settings/system-ai';
case 'review_approvals':
case 'review_findings':
return '/patrol';
default:
return undefined;
}
};
const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | undefined => {
const summary = session?.handoff_summary;
if (!summary) return undefined;
@@ -180,11 +153,6 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
const resourceLabel = formatSessionHandoffResourceLabel(summary);
const resourceDetail = formatSessionHandoffResourceDetail(summary);
const statusLabel = formatSessionHandoffStatus(summary);
const recommendedNextStep = summary.recommended_next_step?.trim() || '';
const recommendedNextStepDetail = summary.recommended_next_step_detail?.trim() || '';
const recommendedNextStepAction = summary.recommended_next_step_action?.trim() || '';
const recommendedNextStepActionKind = summary.recommended_next_step_action_kind?.trim() || '';
const recommendedNextStepActionHref = getSessionHandoffRecommendedActionHref(summary);
const actionCount = summary.action_count ?? 0;
const resourceCount = summary.resource_count ?? 0;
const findingId = summary.finding_id?.trim() || undefined;
@@ -243,11 +211,6 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
lastKnownApprovalStatus: summary.last_known_approval_status,
lastKnownActionState: summary.last_known_action_state,
lastKnownActionRisk: summary.last_known_action_risk,
recommendedNextStep,
recommendedNextStepDetail,
recommendedNextStepAction,
recommendedNextStepActionKind,
recommendedNextStepActionHref,
updatedAt: summary.updated_at,
},
findingId,
@@ -275,15 +238,6 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
resourceCount > 1
? pluralizeCount(resourceCount, 'linked resource', 'linked resources')
: undefined,
isPatrolAssessment && recommendedNextStep
? `Recommended next step: ${recommendedNextStep}`
: undefined,
isPatrolAssessment && recommendedNextStepDetail
? `Reason: ${recommendedNextStepDetail}`
: undefined,
isPatrolAssessment && recommendedNextStepAction
? `Available action: ${recommendedNextStepAction}`
: undefined,
actionCount > 0
? pluralizeCount(actionCount, 'governed action', 'governed actions')
: undefined,
@@ -294,27 +248,14 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
: isPatrolConfigurationFailure
? 'Review Patrol configuration issue'
: isPatrolAssessment
? recommendedNextStepAction
? `Recommended: ${recommendedNextStepAction}`
: recommendedNextStep
? `Recommended: ${recommendedNextStep}`
: 'Review Patrol assessment'
? 'Review Patrol assessment'
: isPatrolRun && summary.runtime_failure
? 'Review Patrol runtime issue'
: isPatrolRun
? 'Review Patrol run'
: isPatrolFinding && recommendedNextStepAction
? recommendedNextStepAction
: actionCount > 0
? 'Governed action context'
: undefined,
actionHref:
!summary.requires_approval &&
recommendedNextStepActionHref &&
(isPatrolFinding ||
(isPatrolAssessment && Boolean(recommendedNextStepAction || recommendedNextStep)))
? recommendedNextStepActionHref
: undefined,
: actionCount > 0
? 'Governed action context'
: undefined,
commandSummary: statusLabel
? isPatrolRun
? `Run state: ${statusLabel}`
@@ -325,37 +266,9 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
: actionCount > 0
? 'Detailed command payloads stay in governed approval context.'
: undefined,
suggestedPrompts: isPatrolRun
? summary.runtime_failure
? [
'Explain this Patrol runtime issue',
'What should I check before retrying?',
'What should I verify next?',
]
: [
'Explain this Patrol run',
'What changed during this run?',
'What should I verify next?',
]
: isPatrolConfigurationFailure
? [
'Explain this Patrol configuration issue',
'What should I check before retrying?',
'What should I change before the next run?',
]
: isPatrolAssessment
? [
'Prioritize findings and safest next step',
'Explain recent changes and correlations',
'What should I verify next?',
]
: isPatrolFinding
? [
'What changed since this finding was opened?',
'What evidence supports this finding?',
'What should I verify next?',
]
: ['Summarize this handoff', 'What needs attention?', 'What should I verify next?'],
suggestedPrompts: isPatrolSessionHandoff(summary)
? []
: ['Summarize this handoff', 'What needs attention?', 'What should I verify next?'],
},
};
};
@@ -1456,15 +1369,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
</span>
)}
</Show>
<Show
when={formatSessionHandoffRecommendationLabel(summary())}
>
{(recommendation) => (
<span class="max-w-full truncate rounded border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
{recommendation()}
</span>
)}
</Show>
</div>
)}
</Show>
@@ -34,7 +34,6 @@ import { AIAPI, type ApprovalRequest, type RemediationPlan } from '@/api/ai';
import { createSuppressionRuleFromFinding } from '@/api/patrol';
import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol';
import { buildResolvedResourceSurfaceLinks } from '@/routing/resourceLinks';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
import { formatRelativeTime } from '@/utils/format';
import { getFindingAlertIdentifier, hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { segmentedButtonClass } from '@/utils/segmentedButton';
@@ -195,45 +194,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
notificationStore.success('Remediation plan dismissed');
};
// Reuses AIAPI.approveRemediationPlan, the existing approval contract for
// remediation plans. The capacity-forecast card surfaces this as
// "Approve proposal" so the operator can record intent on a preflight-only
// proposal (Allowed=false) - the action broker will still refuse execution
// until a Pulse write capability is wired for the resource type. See
// internal/ai/forecast.BuildActionPlanForFinding for the contract.
const handleApproveProposedPlan = async (plan: RemediationPlan, e: Event) => {
e.stopPropagation();
try {
await AIAPI.approveRemediationPlan(plan.id);
setDismissedPlanIds((prev) => [...prev, plan.id]);
notificationStore.success('Capacity-forecast proposal approved');
} catch (err) {
notificationStore.error(
err instanceof Error
? `Failed to approve proposal: ${err.message}`
: 'Failed to approve proposal',
);
}
};
// formatTimeToThreshold collapses a "seconds until threshold breach"
// value into operator-readable copy. Mirrors forecast service phrasing
// ("3 days", "12 hours") so the proposal card and the existing forecast
// overview don't drift in tone.
const formatTimeToThreshold = (seconds?: number | null): string => {
if (!seconds || seconds <= 0) {
return '';
}
const hours = seconds / 3600;
if (hours < 1) {
return `${Math.max(1, Math.round(seconds / 60))} min`;
}
if (hours < 48) {
return `${Math.round(hours)} hr`;
}
return `${Math.round(hours / 24)} days`;
};
// Map of finding_id -> latest remediation plan artifact
const plansByFindingId = createMemo(() => {
const dismissedPlanIdsSet = new Set(dismissedPlanIds());
@@ -680,7 +640,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
);
const proposedFix =
latestInvestigationProposedFix || buildLiveApprovalProposedFixBriefing(pendingApproval);
const nextStepAction = getFindingPrimaryActionPresentation(finding);
const handoff = buildPatrolAssistantFindingHandoff({
id: finding.id,
title,
@@ -703,7 +662,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
pendingApproval: pendingApprovalBriefing,
proposedFix,
investigationRecord: finding.investigationRecord,
nextStepAction,
intent,
});
// Explain, Investigate, and Why-did-this-happen are action-style
@@ -1759,173 +1717,33 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
/>
</Show>
{/* Existing action artifact: compact review entry, not a Patrol-authored fix answer. */}
{/* Existing model-owned action artifact: compact Assistant review entry. */}
<Show when={finding.status === 'active' && plansByFindingId().get(finding.id)}>
{(plan) => {
// Capacity-forecast proposal variant - rendered when patrol
// attached a deterministic forecast-driven ActionPlan via the
// template registry in internal/ai/forecast. Other plan artifacts
// collapse to a compact Assistant review entry.
const proposal = () => plan().proposed_action_plan;
const isCapacityForecastProposal = () =>
finding.category === 'capacity' && proposal()?.source === 'capacity_forecast';
return (
<div class="mt-3 pt-3 border-t border-border-subtle">
<Show
when={isCapacityForecastProposal()}
fallback={(() => {
const planRisk = getApprovalRiskPresentation(plan().risk_level);
return (
<>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<AlertCircleIcon class="h-4 w-4 flex-shrink-0 text-muted" />
<span class="text-sm font-medium text-base-content">Action review</span>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${planRisk.badgeClass}`}
>
{planRisk.label} risk
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
>
Ask Assistant
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
>
Dismiss
</button>
</div>
</div>
</>
);
})()}
>
{(() => {
const p = proposal()!;
const metric = p.projectedMetric;
const preflight = p.preflight;
const ttbLabel = formatTimeToThreshold(metric?.timeToThresholdSeconds);
return (
<div data-testid="capacity-forecast-approval-card">
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 17l6-6 4 4 8-8M21 7v6h-6"
/>
</svg>
<span class="text-sm font-medium text-base-content">
Capacity-forecast proposal
</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300">
requires approval
</span>
<Show when={p.allowed === false}>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded bg-surface-alt text-muted">
preflight only
</span>
</Show>
</div>
<Show when={metric}>
<div class="text-xs text-muted mb-2">
<span class="font-medium text-base-content">
{metric!.currentValue.toFixed(1)}%
</span>{' '}
now
<Show
when={
typeof metric!.predictedValue === 'number' &&
metric!.predictedValue !== metric!.currentValue
}
>
{' '}
<span aria-hidden="true"></span>{' '}
<span class="font-medium text-base-content">
{metric!.predictedValue!.toFixed(1)}%
</span>{' '}
projected
</Show>
<Show
when={
typeof metric!.thresholdValue === 'number' &&
metric!.thresholdValue! > 0
}
>
{' '}
· threshold {metric!.thresholdValue!.toFixed(0)}%
</Show>
<Show when={ttbLabel}> · breach in {ttbLabel}</Show>
</div>
</Show>
<Show when={p.message}>
<p class="text-sm text-base-content mb-2">{p.message}</p>
</Show>
<Show when={preflight}>
<Show when={preflight!.intendedChange}>
<div class="text-xs text-muted mb-1">
<span class="font-medium text-base-content">Proposed change:</span>{' '}
{preflight!.intendedChange}
</div>
</Show>
<Show
when={preflight!.safetyChecks && preflight!.safetyChecks!.length > 0}
>
<ul class="text-[11px] text-muted list-disc list-inside space-y-0.5 mb-2">
<For each={preflight!.safetyChecks!}>
{(check) => <li>{check}</li>}
</For>
</ul>
</Show>
</Show>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<button
type="button"
onClick={(e) => handleApproveProposedPlan(plan(), e)}
class="flex-1 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
data-testid="capacity-forecast-approve"
>
Approve proposal
</button>
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="px-3 py-1.5 border border-border hover:bg-surface-hover text-base-content text-xs font-medium rounded"
>
Discuss
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
data-testid="capacity-forecast-reject"
>
Reject
</button>
</div>
</div>
);
})()}
</Show>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<AlertCircleIcon class="h-4 w-4 flex-shrink-0 text-muted" />
<span class="text-sm font-medium text-base-content">Assistant context</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleOpenPlanInAssistant(finding, plan(), e)}
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1.5"
>
Ask Assistant
</button>
<button
type="button"
onClick={(e) => handleDismissPlan(plan(), e)}
class="px-3 py-1.5 hover:bg-surface-hover text-muted text-xs font-medium rounded"
>
Dismiss
</button>
</div>
</div>
</div>
);
}}
@@ -1,312 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FindingsPanel } from '../FindingsPanel';
// Pin the registry constant locally so the tests fail loudly if Go renames
// the source on the wire (see internal/ai/forecast.CapacityActionPlanSource).
const CAPACITY_FORECAST_SOURCE = 'capacity_forecast';
type RemediationPlanFixture = {
id: string;
finding_id: string;
resource_id: string;
title: string;
description: string;
steps: Array<{ order: number; action: string; risk_level: 'low' | 'medium' | 'high' }>;
risk_level: 'low' | 'medium' | 'high';
status: 'pending' | 'approved';
created_at: string;
proposed_action_plan?: {
actionId: string;
allowed: boolean;
requiresApproval: boolean;
source?: string;
message?: string;
projectedMetric?: {
metric: string;
currentValue: number;
predictedValue?: number;
thresholdValue?: number;
timeToThresholdSeconds?: number | null;
};
preflight?: {
intendedChange?: string;
dryRunAvailable: boolean;
safetyChecks?: string[];
};
};
};
const baseStep = { order: 1, action: 'Investigate', risk_level: 'low' as const };
function makeFinding(overrides: Record<string, unknown>) {
return {
id: 'finding-cap',
source: 'ai-patrol',
resourceId: 'node-a/storage/tank',
resourceName: 'tank',
resourceType: 'storage',
category: 'capacity',
severity: 'warning',
title: 'Storage pool tank at 87.3% usage',
description: 'Tank pool exceeded warning threshold.',
detectedAt: '2026-04-01T10:00:00Z',
lastSeenAt: '2026-04-01T10:05:00Z',
status: 'active',
...overrides,
};
}
function makePlan(findingId: string, withProposal: boolean): RemediationPlanFixture {
const plan: RemediationPlanFixture = {
id: `plan-${findingId}`,
finding_id: findingId,
resource_id: 'node-a/storage/tank',
title: 'Fix: Storage pool tank at 87.3% usage',
description: 'Tank pool exceeded warning threshold.',
steps: [baseStep],
risk_level: 'medium',
status: 'pending',
created_at: '2026-04-01T10:05:00Z',
};
if (withProposal) {
plan.proposed_action_plan = {
actionId: 'capacity-forecast-abc',
allowed: false,
requiresApproval: true,
source: CAPACITY_FORECAST_SOURCE,
message: 'Storage pool "tank" is at 87.3% usage. Propose: prune oldest auto-snapshots.',
projectedMetric: {
metric: 'usage_percent',
currentValue: 87.3,
predictedValue: 93.5,
thresholdValue: 75,
timeToThresholdSeconds: 36 * 3600,
},
preflight: {
intendedChange: 'Prune oldest auto-snapshots, then list largest reclaimable datasets.',
dryRunAvailable: false,
safetyChecks: [
'Operator must explicitly approve before any execution path is wired.',
'This proposal ships with Allowed=false; the action broker will refuse execution.',
],
},
};
}
return plan;
}
const mockState = vi.hoisted(() => {
const loadFindings = vi.fn();
const loadPatrolFindings = vi.fn();
const loadRemediationPlans = vi.fn();
const approveRemediationPlan = vi.fn().mockResolvedValue({ success: true });
return {
findings: [] as unknown[],
remediationPlans: [] as unknown[],
loadFindings,
loadPatrolFindings,
loadRemediationPlans,
approveRemediationPlan,
};
});
vi.mock('@solidjs/router', () => ({
A: (props: { href: string; children?: JSX.Element; [key: string]: unknown }) => (
<a href={props.href} aria-label={props['aria-label'] as string} onClick={props.onClick as any}>
{props.children}
</a>
),
useLocation: () => ({ hash: '' }),
}));
vi.mock('@/components/shared/Card', () => ({
Card: (props: { children?: JSX.Element }) => <div>{props.children}</div>,
}));
vi.mock('@/components/patrol', () => ({
InvestigationSection: () => null,
ApprovalSection: () => null,
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/stores/aiChat', () => ({
aiChatStore: {
openWithPrompt: vi.fn(),
},
}));
vi.mock('@/api/ai', () => ({
AIAPI: {
approveRemediationPlan: (...args: unknown[]) => mockState.approveRemediationPlan(...args),
},
}));
vi.mock('@/hooks/useResources', () => ({
useResources: () => ({
get: () => undefined,
}),
}));
vi.mock('@/stores/aiIntelligence', () => ({
aiIntelligenceStore: {
get findings() {
return mockState.findings;
},
get findingsLoading() {
return false;
},
get findingsError() {
return null;
},
get patrolFindings() {
return mockState.findings;
},
get patrolFindingsLoading() {
return false;
},
get patrolFindingsError() {
return null;
},
get findingsNeedingAttention() {
return [];
},
get patrolFindingsNeedingAttention() {
return [];
},
get findingsWithPendingApprovals() {
return [];
},
get patrolFindingsWithPendingApprovals() {
return [];
},
get remediationPlans() {
return mockState.remediationPlans;
},
findingsSignal: () => mockState.findings,
patrolFindingsSignal: () => mockState.findings,
loadFindings: mockState.loadFindings,
loadPatrolFindings: mockState.loadPatrolFindings,
loadRemediationPlans: mockState.loadRemediationPlans,
},
}));
beforeEach(() => {
mockState.findings = [];
mockState.remediationPlans = [];
mockState.loadFindings.mockClear();
mockState.loadPatrolFindings.mockClear();
mockState.loadRemediationPlans.mockClear();
mockState.approveRemediationPlan.mockClear();
if (typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(
() => callback(performance.now()),
0,
)) as typeof window.requestAnimationFrame;
}
});
afterEach(() => {
vi.clearAllMocks();
});
describe('FindingsPanel capacity-forecast approval card', () => {
it('renders the capacity-forecast card when a capacity finding has a forecast-driven proposal', async () => {
const finding = makeFinding({ id: 'finding-cap-1' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
const card = screen.getByTestId('capacity-forecast-approval-card');
expect(card).toBeInTheDocument();
expect(within(card).getByText(/Capacity-forecast proposal/i)).toBeInTheDocument();
expect(within(card).getByText(/requires approval/i)).toBeInTheDocument();
expect(within(card).getByText(/preflight only/i)).toBeInTheDocument();
// Operator-facing snapshot must surface current/predicted/threshold and TTB.
// Current value appears in two places inside the card: the projected
// metric snapshot and the proposal message. Both are intentional - the
// first lets the operator see the projection at a glance, the second
// restates it inside the rationale paragraph. Use *AllBy* to match
// both without locking the exact placement.
expect(within(card).getAllByText(/87\.3%/).length).toBeGreaterThanOrEqual(1);
expect(within(card).getAllByText(/93\.5%/).length).toBeGreaterThanOrEqual(1);
expect(within(card).getByText(/threshold 75%/)).toBeInTheDocument();
expect(within(card).getByText(/breach in/i)).toBeInTheDocument();
// The generic "Ask Assistant" affordance must NOT appear when the
// forecast variant is active - the generic card is gated by fallback.
expect(screen.queryByText('Ask Assistant')).not.toBeInTheDocument();
});
it('renders compact action review when a capacity finding has no proposal attached', async () => {
const finding = makeFinding({ id: 'finding-cap-2' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, false)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
expect(screen.queryByTestId('capacity-forecast-approval-card')).not.toBeInTheDocument();
expect(screen.getByText('Action review')).toBeInTheDocument();
expect(screen.getByText('Ask Assistant')).toBeInTheDocument();
expect(screen.queryByText('Remediation Plan')).not.toBeInTheDocument();
});
it('never renders the capacity-forecast card for a non-capacity finding even if a proposal somehow attaches', async () => {
// This case should not occur in production - the wire-in only attaches
// proposals to capacity findings - but the frontend gate must stay
// closed regardless. A drifted backend that mis-categorises a finding
// must not leak the capacity-forecast card onto the wrong finding type.
const finding = makeFinding({
id: 'finding-perf-1',
category: 'performance',
title: 'High CPU on appserver: 92.0%',
});
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('High CPU on appserver: 92.0%'));
expect(screen.queryByTestId('capacity-forecast-approval-card')).not.toBeInTheDocument();
expect(screen.getByText('Action review')).toBeInTheDocument();
expect(screen.getByText('Ask Assistant')).toBeInTheDocument();
});
it('routes Approve through AIAPI.approveRemediationPlan and Reject through dismiss without bypassing handlers', async () => {
const finding = makeFinding({ id: 'finding-cap-3' });
mockState.findings = [finding];
mockState.remediationPlans = [makePlan(finding.id, true)];
render(() => <FindingsPanel findingsSource="patrol" />);
await waitFor(() => expect(mockState.loadRemediationPlans).toHaveBeenCalled());
fireEvent.click(screen.getByText('Storage pool tank at 87.3% usage'));
fireEvent.click(screen.getByTestId('capacity-forecast-approve'));
await waitFor(() =>
expect(mockState.approveRemediationPlan).toHaveBeenCalledWith('plan-finding-cap-3'),
);
});
});
@@ -66,10 +66,7 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
expect(findingsPanelSource).toContain('pendingApproval: pendingApprovalBriefing');
expect(findingsPanelSource).toContain('proposedFix,');
expect(findingsPanelSource).toContain(
'const nextStepAction = getFindingPrimaryActionPresentation(finding);',
);
expect(findingsPanelSource).toContain('nextStepAction,');
expect(findingsPanelSource).not.toContain('nextStepAction,');
expect(findingsPanelSource).toContain('AIAPI.getInvestigation(finding.id)');
expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()');
});
@@ -238,38 +235,19 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('text-amber-600 dark:text-amber-400');
});
it('renders a distinct capacity-forecast approval card when a forecast-driven proposal is attached', () => {
// The capacity-forecast card variant is only rendered when the
// RemediationPlan carries a proposed_action_plan with
// source === 'capacity_forecast' AND the finding category is
// 'capacity'. Pin the wiring so the variant doesn't accidentally take
// over the generic remediation plan card.
expect(findingsPanelSource).toContain("finding.category === 'capacity'");
expect(findingsPanelSource).toContain("proposal()?.source === 'capacity_forecast'");
expect(findingsPanelSource).toContain('isCapacityForecastProposal()');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-approval-card"');
// Card must surface current/projected/threshold so the operator can
// decide without digging into Patrol metrics.
expect(findingsPanelSource).toContain('metric!.currentValue.toFixed(1)');
expect(findingsPanelSource).toContain('metric!.predictedValue!.toFixed(1)');
expect(findingsPanelSource).toContain('metric!.thresholdValue!.toFixed(0)');
// Approval-gated by contract; the badge must communicate that even when
// the action is preflight-only (Allowed=false).
expect(findingsPanelSource).toContain('requires approval');
expect(findingsPanelSource).toContain('p.allowed === false');
expect(findingsPanelSource).toContain('preflight only');
// Must reuse the existing approval handlers - approve flows through
// AIAPI.approveRemediationPlan, reject flows through handleDismissPlan.
// No bypass / parallel state machine.
expect(findingsPanelSource).toContain('handleApproveProposedPlan');
expect(findingsPanelSource).toContain('AIAPI.approveRemediationPlan(plan.id)');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-approve"');
expect(findingsPanelSource).toContain('data-testid="capacity-forecast-reject"');
// The fallback path keeps non-capacity action artifacts compact for
// Assistant review rather than rendering Patrol-authored steps.
expect(findingsPanelSource).toContain('fallback={(()');
expect(findingsPanelSource).toContain('Action review');
it('keeps remediation artifacts as compact Assistant context only', () => {
// Patrol findings should not grow a frontend-authored proposal surface.
// Any existing artifact is a pointer for Assistant review, not a visible
// fix plan, capacity proposal, or approval bypass.
expect(findingsPanelSource).toContain('Assistant context');
expect(findingsPanelSource).toContain('Ask Assistant');
expect(findingsPanelSource).toContain('handleOpenPlanInAssistant');
expect(findingsPanelSource).toContain('handleDismissPlan');
expect(findingsPanelSource).not.toContain('capacity_forecast');
expect(findingsPanelSource).not.toContain('Capacity-forecast proposal');
expect(findingsPanelSource).not.toContain('Approve proposal');
expect(findingsPanelSource).not.toContain('handleApproveProposedPlan');
expect(findingsPanelSource).not.toContain('AIAPI.approveRemediationPlan(plan.id)');
expect(findingsPanelSource).not.toContain('Remediation Plan');
expect(findingsPanelSource).not.toContain('<For each={plan().steps}>');
});
@@ -127,7 +127,7 @@ describe('ApprovalSection', () => {
expect(prompt).toContain(
'I\'d like to discuss this Patrol finding: "CPU saturation" on node-1',
);
expect(prompt).toContain('Start by reviewing the governed action posture');
expect(prompt).toContain('Governed action posture is attached');
expect(prompt).toContain('Recover or regenerate the governed approval before execution');
expect(context).toEqual(
expect.objectContaining({
@@ -143,11 +143,6 @@ describe('ApprovalSection', () => {
expect.stringContaining('fix queued for governed review'),
expect.stringContaining('Recover or regenerate the governed approval before execution'),
]),
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'List approval prerequisites before action',
],
}),
autonomousMode: false,
handoffResources: [{ id: 'host-1', name: 'node-1', node: undefined, type: 'host' }],
@@ -202,7 +197,7 @@ describe('ApprovalSection', () => {
expect(openWithPromptMock).toHaveBeenCalledTimes(1);
const [prompt, context] = openWithPromptMock.mock.calls[0];
expect(prompt).toContain('Start by reviewing governed approval approval-1');
expect(prompt).toContain('Governed approval approval-1 is attached');
expect(prompt).toContain('approval status pending');
expect(prompt).toContain('high risk');
expect(prompt).not.toContain('systemctl restart nginx');
@@ -227,11 +222,6 @@ describe('ApprovalSection', () => {
commandSummary: '1 command recorded for approval context',
safetyNote:
'Command details stay in approval context; execution requires the governed approval flow.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'Summarize remediation without command text',
],
}),
autonomousMode: false,
handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }],
@@ -305,7 +295,7 @@ describe('ApprovalSection', () => {
expect(openWithPromptMock).toHaveBeenCalledTimes(1);
const [prompt, context] = openWithPromptMock.mock.calls[0];
expect(prompt).toContain('Start by reviewing the governed action posture');
expect(prompt).toContain('Governed action posture is attached');
expect(prompt).toContain('recorded action artifact Restart the workload service');
expect(prompt).toContain('target node-1');
expect(prompt).toContain('high risk');
@@ -329,11 +319,6 @@ describe('ApprovalSection', () => {
commandSummary: '1 command recorded for approval context',
safetyNote:
'Command details stay in approval context; destructive actions require governed approval.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'Summarize remediation without command text',
],
}),
autonomousMode: false,
handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }],
@@ -1,32 +1,13 @@
import { createMemo, Show } from 'solid-js';
import ActivityIcon from 'lucide-solid/icons/activity';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
import MessageSquareIcon from 'lucide-solid/icons/message-square';
import PlayIcon from 'lucide-solid/icons/play';
import SettingsIcon from 'lucide-solid/icons/settings';
import {
getPatrolAssessmentShellPresentation,
getPatrolAssessmentPresentation,
getPatrolRecommendedNextStepPresentation,
getPatrolRecencyPresentation,
getPatrolScoreChipLabel,
getPatrolVerificationPresentation,
getPatrolSummaryMetricState,
type PatrolRecommendedNextStepAction,
} from '@/utils/patrolSummaryPresentation';
import { getPatrolLatestRunPresentation } from '@/utils/patrolRunPresentation';
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
import { formatRelativeTime } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import type { ApprovalRequest } from '@/api/ai';
import {
buildPatrolAssessmentAssistantHandoff,
buildPatrolAssistantApprovalBriefingInput,
buildPatrolAssistantProposedFixBriefingInput,
type PatrolAssessmentAssistantFindingInput,
} from './patrolInvestigationContextModel';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
function PatrolAssessmentLoadingShell() {
@@ -106,13 +87,6 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
runs: state.patrolRunHistory.value() ?? [],
}),
);
const verification = createMemo(() =>
getPatrolVerificationPresentation({
runs: state.patrolRunHistory.value() ?? [],
runtimeState: state.runtimeState(),
blockedReason: state.blockedReason(),
}),
);
const recency = createMemo(() =>
getPatrolRecencyPresentation({
runs: state.patrolRunHistory.value() ?? [],
@@ -120,23 +94,6 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
lastActivityAt: state.patrolStatus()?.last_activity_at,
}),
);
const latestRun = createMemo(() =>
getPatrolLatestRunPresentation(state.patrolRunHistory.value() ?? []),
);
const scoreChipLabel = createMemo(() =>
getPatrolScoreChipLabel({
overallHealth: state.intelligenceSummary()?.overall_health,
activeFindings: state.activePatrolFindings(),
}),
);
const recommendedNextStep = createMemo(() =>
getPatrolRecommendedNextStepPresentation({
assessment: assessment(),
verification: verification(),
activeFindings: state.activePatrolFindings(),
pendingApprovalCount: aiIntelligenceStore.patrolPendingApprovals.length,
}),
);
const compactRiskSummary = createMemo(() => {
const stats = summaryStats();
const parts: string[] = [];
@@ -180,118 +137,6 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
return parts.join(' · ');
});
const recommendedNextStepAction = createMemo(() => recommendedNextStep().action);
const recommendedNextStepActionDisabled = createMemo(() => {
const action = recommendedNextStepAction();
return (
action?.kind === 'run_patrol' &&
(state.isTriggeringPatrol() ||
!state.canTriggerPatrol() ||
state.manualRunRequested() ||
state.patrolStream.isStreaming())
);
});
const recommendedNextStepActionDisabledReason = createMemo(() =>
recommendedNextStepActionDisabled() ? state.triggerPatrolDisabledReason() : '',
);
const recommendedNextStepActionLabel = createMemo(() => {
const action = recommendedNextStepAction();
if (action?.kind !== 'run_patrol') {
return action?.label;
}
if (state.isTriggeringPatrol()) {
return 'Starting...';
}
if (state.manualRunRequested() || state.patrolStream.isStreaming()) {
return 'Running...';
}
return action.label;
});
const activeFindingsWithApprovalContext = createMemo<PatrolAssessmentAssistantFindingInput[]>(
() => {
const approvalsByFindingId = new Map(
aiIntelligenceStore.patrolPendingApprovals.map((approval) => [approval.targetId, approval]),
);
return state.activePatrolFindings().map((finding) => {
const approval = approvalsByFindingId.get(finding.id);
if (!approval) {
return finding;
}
return {
...finding,
pendingApproval: buildPatrolAssessmentApprovalBriefing(approval),
proposedFix: finding.investigationRecord?.proposed_fix
? undefined
: buildPatrolAssessmentApprovalProposedFixBriefing(approval),
};
});
},
);
const assessmentAssistantHandoff = createMemo(() => {
const recommendation = recommendedNextStep();
return buildPatrolAssessmentAssistantHandoff({
assessment: assessment(),
overallHealth: state.intelligenceSummary()?.overall_health,
scoreChipLabel: scoreChipLabel(),
metricState: metricState(),
verification: verification(),
recency: recency(),
latestRun: latestRun(),
investigationContext: {
recentChangeCount: state.recentChangeCount(),
correlationCount: state.correlationTotal(),
governedResourceCount: state.policyPosture()?.total_resources ?? 0,
hasContext: state.hasInvestigationContext(),
summaryText: state.investigationContextSummary(),
},
supportingEvidence: {
recentChanges: state.supportingRecentChanges(),
correlations: state.correlations(),
},
recommendedNextStep: {
title: recommendation.title,
description: recommendation.description,
actionLabel: recommendation.action?.label,
actionKind: recommendation.action?.kind,
actionDisabledReason: recommendedNextStepActionDisabledReason(),
},
activeFindings: activeFindingsWithApprovalContext(),
});
});
const handleDiscussAssessment = async () => {
await aiIntelligenceStore.loadPendingApprovals();
const handoff = assessmentAssistantHandoff();
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
};
const handleRecommendedNextStepAction = (action: PatrolRecommendedNextStepAction) => {
switch (action.kind) {
case 'discuss_assessment':
void handleDiscussAssessment();
return;
case 'review_approvals':
state.setSelectedRun(null);
state.setActiveTab('findings');
state.setFindingsFilterOverride('approvals');
return;
case 'review_findings':
state.setSelectedRun(null);
state.setActiveTab('findings');
state.setFindingsFilterOverride('active');
return;
case 'run_patrol':
void state.handleRunPatrol();
return;
case 'open_provider_settings':
return;
}
};
return (
<>
<Show when={showLoadingSummary()}>
@@ -350,57 +195,6 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
</span>
<span class="font-semibold text-base-content">{compactAssessmentSummary()}</span>
</div>
<div
data-testid="patrol-recommended-next-step"
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted"
>
<span class="font-medium text-base-content">Next:</span>
<span class="font-medium text-base-content">{recommendedNextStep().title}</span>
</div>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
<Show when={recommendedNextStepAction()}>
{(action) => (
<Show
when={action().href}
fallback={
<button
type="button"
data-testid="patrol-recommended-next-step-action"
disabled={recommendedNextStepActionDisabled()}
title={
action().kind === 'run_patrol'
? state.triggerPatrolDisabledReason()
: undefined
}
class="inline-flex shrink-0 items-center gap-1.5 rounded border border-border-subtle bg-transparent px-2.5 py-1.5 text-xs font-semibold text-base-content transition-colors hover:bg-surface-hover disabled:text-muted"
onClick={() => handleRecommendedNextStepAction(action())}
>
{renderRecommendedNextStepActionIcon(
action(),
state.isTriggeringPatrol() ||
state.manualRunRequested() ||
state.patrolStream.isStreaming(),
)}
<span>{recommendedNextStepActionLabel()}</span>
</button>
}
>
{(href) => (
<a
href={href()}
data-testid="patrol-recommended-next-step-action"
class="inline-flex shrink-0 items-center gap-1.5 rounded border border-border-subtle bg-transparent px-2.5 py-1.5 text-xs font-semibold text-base-content transition-colors hover:bg-surface-hover"
>
{renderRecommendedNextStepActionIcon(action(), false)}
<span>{action().label}</span>
</a>
)}
</Show>
)}
</Show>
</div>
</div>
</section>
@@ -409,36 +203,3 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
</>
);
}
function buildPatrolAssessmentApprovalBriefing(approval: ApprovalRequest) {
return buildPatrolAssistantApprovalBriefingInput(approval);
}
function buildPatrolAssessmentApprovalProposedFixBriefing(approval: ApprovalRequest) {
return buildPatrolAssistantProposedFixBriefingInput({
description: approval.context,
riskLevel: approval.riskLevel,
targetHost: approval.targetName,
commandCount: approval.command ? 1 : 0,
});
}
function renderRecommendedNextStepActionIcon(
action: PatrolRecommendedNextStepAction,
running: boolean,
) {
const iconClass = `h-4 w-4 ${running && action.kind === 'run_patrol' ? 'animate-pulse' : ''}`;
switch (action.kind) {
case 'open_provider_settings':
return <SettingsIcon class={iconClass} aria-hidden="true" />;
case 'run_patrol':
return <PlayIcon class={iconClass} aria-hidden="true" />;
case 'discuss_assessment':
return <MessageSquareIcon class={iconClass} aria-hidden="true" />;
case 'review_approvals':
return <CheckCircleIcon class={iconClass} aria-hidden="true" />;
case 'review_findings':
return <ActivityIcon class={iconClass} aria-hidden="true" />;
}
}
@@ -1,7 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import type { PatrolRunRecord } from '@/api/patrol';
import { PatrolIntelligenceSummary } from '../PatrolIntelligenceSummary';
import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState';
@@ -12,55 +11,20 @@ describe('PatrolIntelligenceSummary', () => {
vi.restoreAllMocks();
});
it('routes pending approvals from the compact assessment strip', () => {
vi.spyOn(aiIntelligenceStore, 'patrolPendingApprovals', 'get').mockReturnValue([
{
id: 'approval-1',
toolId: 'investigation_fix',
command: 'systemctl restart workload.service',
targetType: 'finding',
targetId: 'finding-1',
targetName: 'web-server',
context: 'Restart the workload service after backup pressure clears.',
riskLevel: 'high',
status: 'pending',
requestedAt: '2026-05-06T12:00:00Z',
expiresAt: '2026-05-06T12:10:00Z',
plan: {
actionId: 'action-1',
requiresApproval: true,
approvalPolicy: 'admin',
message: 'Restart after the backup window clears.',
expiresAt: '2026-05-06T12:10:00Z',
},
preflight: {
intendedChange: 'Restart workload service',
dryRunAvailable: false,
dryRunSummary: 'No provider-supported dry run is available for this action.',
},
},
]);
it('keeps the compact assessment strip descriptive only', () => {
const patrolState = createPatrolState();
render(() => <PatrolIntelligenceSummary state={patrolState} />);
expect(screen.getByTestId('patrol-recommended-next-step').textContent).toContain(
'Review the pending Patrol approval',
);
expect(screen.getByText('Patrol assessment')).toBeInTheDocument();
expect(screen.getByText('1 critical · 84/100')).toBeInTheDocument();
expect(screen.queryByTestId('patrol-recommended-next-step')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-recommended-next-step-action')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-summary-details-toggle')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-summary-details')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-assessment-assistant-button')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('patrol-recommended-next-step-action'));
expect(patrolState.setSelectedRun).toHaveBeenCalledWith(null);
expect(patrolState.setActiveTab).toHaveBeenCalledWith('findings');
expect(patrolState.setFindingsFilterOverride).toHaveBeenCalledWith('approvals');
});
it('keeps unavailable run actions on the compact assessment strip', () => {
vi.spyOn(aiIntelligenceStore, 'patrolPendingApprovals', 'get').mockReturnValue([]);
it('does not surface disabled run actions on the compact assessment strip', () => {
const patrolState = {
...createPatrolState(),
activePatrolFindings: () => [],
@@ -144,10 +108,8 @@ describe('PatrolIntelligenceSummary', () => {
render(() => <PatrolIntelligenceSummary state={patrolState} />);
const action = screen.getByTestId('patrol-recommended-next-step-action');
expect(action).toHaveTextContent('Running...');
expect(action).toBeDisabled();
expect(action).toHaveAttribute('title', 'Patrol is already running');
expect(screen.queryByTestId('patrol-recommended-next-step')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-recommended-next-step-action')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-summary-details-toggle')).not.toBeInTheDocument();
});
});
@@ -360,7 +360,7 @@ describe('patrolInvestigationContextModel', () => {
});
expect(handoff.prompt).toContain('Discuss the current Pulse Patrol assessment');
expect(handoff.prompt).toContain('Start by reviewing 1 governed action reference');
expect(handoff.prompt).toContain('1 governed action reference');
expect(handoff.prompt).toContain('Do not infer, repeat, or execute raw command text');
expect(handoff.context.autonomousMode).toBe(false);
expect(handoff.context.handoffContext).toContain('[Patrol Assessment Context]');
@@ -396,16 +396,12 @@ describe('patrolInvestigationContextModel', () => {
actionLabel: '1 governed action reference attached',
safetyNote:
'Review action posture in the governed flow; raw command payloads stay out of Assistant.',
suggestedPrompts: [
'Prioritize findings and safest next step',
'Explain recent changes and correlations',
'Summarize governed remediation risks',
],
});
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service');
});
it('frames coverage-incomplete assessment handoffs as a verification gap', () => {
it('frames coverage-incomplete assessment handoffs as evidence-only context', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Coverage incomplete',
@@ -448,37 +444,21 @@ describe('patrolInvestigationContextModel', () => {
hasContext: true,
summaryText: '100 recent changes · 29 correlations · 116 policy-covered resources',
},
recommendedNextStep: {
title: 'Verify full coverage',
description:
'Run a full Patrol sweep before treating this assessment as an all-clear; recent evidence is incomplete or limited to targeted activity.',
actionLabel: 'Run Patrol',
actionKind: 'run_patrol',
},
activeFindings: [],
});
expect(handoff.prompt).toContain('why Patrol coverage is incomplete');
expect(handoff.prompt).toContain('what the latest scoped activity did and did not prove');
expect(handoff.prompt).toContain(
'Patrol\'s visible recommended next step is "Verify full coverage"',
);
expect(handoff.prompt).toContain('available Patrol-owned action: Run Patrol');
expect(handoff.prompt).toContain('incomplete Patrol coverage signals');
expect(handoff.prompt).toContain('Decide from the evidence whether more verification is needed');
expect(handoff.prompt).not.toContain('next-step metadata');
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Run Patrol',
actionLabel: 'Discuss Patrol coverage',
safetyNote:
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.',
suggestedPrompts: [
'Explain why coverage is incomplete',
'Explain scoped activity and full-run gap',
'Identify early warning signals before full verification',
],
'Assistant can review the coverage evidence; Patrol runs, diagnostics, and remediation remain governed controls.',
});
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(handoff.context.briefing?.actionHref).toBeUndefined();
expect(handoff.context.handoffContext).toContain('Assessment: Coverage incomplete');
expect(handoff.context.handoffContext).toContain('Recommended Next Step: Verify full coverage');
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action: Run Patrol (run_patrol)',
);
expect(handoff.context.handoffContext).not.toContain('Recommended Next Step');
expect(handoff.context.handoffContext).toContain(
'Supporting Context: 100 recent changes · 29 correlations · 116 policy-covered resources',
);
@@ -487,21 +467,14 @@ describe('patrolInvestigationContextModel', () => {
targetId: 'pulse-patrol-assessment',
autonomousMode: false,
});
expect(handoff.context.context).toMatchObject({
recommendedNextStepTitle: 'Verify full coverage',
recommendedNextStepActionKind: 'run_patrol',
});
expect(handoff.context.handoffMetadata).toMatchObject({
expect(handoff.context.context).not.toHaveProperty('recommendedNextStepTitle');
expect(handoff.context.context).not.toHaveProperty('recommendedNextStepActionKind');
expect(handoff.context.handoffMetadata).toEqual({
kind: 'patrol_assessment',
recommendedNextStep: 'Verify full coverage',
recommendedNextStepDetail:
'Run a full Patrol sweep before treating this assessment as an all-clear; recent evidence is incomplete or limited to targeted activity.',
recommendedNextStepAction: 'Run Patrol',
recommendedNextStepActionKind: 'run_patrol',
});
});
it('prioritizes active findings over secondary coverage caveats in assessment handoffs', () => {
it('surfaces active findings as facts rather than Patrol recommendations', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Issues detected',
@@ -535,13 +508,6 @@ describe('patrolInvestigationContextModel', () => {
hasContext: true,
summaryText: '3 recent changes · 70 correlations · 55 policy-covered resources',
},
recommendedNextStep: {
title: 'Review active findings',
description:
'Use the findings workspace to prioritize current risk, recent changes, and governed remediation.',
actionLabel: 'Review findings',
actionKind: 'review_findings',
},
activeFindings: [
{
id: 'finding-backup',
@@ -555,147 +521,14 @@ describe('patrolInvestigationContextModel', () => {
],
});
expect(handoff.prompt).toContain('Start by prioritizing 1 active finding');
expect(handoff.prompt).not.toContain('why Patrol coverage is incomplete');
expect(handoff.prompt).not.toContain('what the latest scoped activity did and did not prove');
expect(handoff.prompt).toContain('1 active finding');
expect(handoff.prompt).not.toContain('next-step metadata');
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Review findings',
safetyNote:
'Assistant can explain the Patrol recommendation; Patrol runs, settings changes, diagnostics, and remediation remain operator-controlled.',
suggestedPrompts: [
'Prioritize findings and safest next step',
'Explain recent changes and correlations',
'List evidence to verify before action',
],
actionLabel: 'Discuss Patrol assessment',
safetyNote: 'Diagnostics and remediation require governed approval.',
});
});
it('links route-owned Patrol assessment recommendations in Assistant briefing', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Patrol runtime issue',
description: 'Patrol coverage is incomplete.',
},
overallHealth: {
grade: 'C',
score: 60,
factors: [{ category: 'coverage' }],
},
recommendedNextStep: {
title: 'Restore Patrol visibility',
description: 'Fix the Patrol runtime issue before treating the assessment as current.',
actionLabel: 'Open Patrol provider settings',
actionKind: 'open_provider_settings',
},
activeFindings: [],
});
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Open Patrol provider settings',
actionHref: '/settings/system-ai',
suggestedPrompts: [
'Explain why Patrol visibility is blocked',
'What should I check in provider settings?',
'What should I verify after restoring Patrol?',
],
});
expect(handoff.context.briefing?.detailLines).toEqual(
expect.arrayContaining([
'Recommended next step: Restore Patrol visibility',
'Reason: Fix the Patrol runtime issue before treating the assessment as current.',
'Available action: Open Patrol provider settings',
]),
);
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action: Open Patrol provider settings (open_provider_settings)',
);
expect(handoff.context.context).toMatchObject({
recommendedNextStepActionKind: 'open_provider_settings',
});
expect(handoff.context.handoffMetadata).toMatchObject({
kind: 'patrol_assessment',
recommendedNextStep: 'Restore Patrol visibility',
recommendedNextStepDetail:
'Fix the Patrol runtime issue before treating the assessment as current.',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionKind: 'open_provider_settings',
recommendedNextStepActionHref: '/settings/system-ai',
});
});
it('marks unavailable recommended Patrol actions in assessment handoffs', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Coverage incomplete',
description: 'Patrol coverage is incomplete.',
},
overallHealth: {
grade: 'C',
score: 65,
factors: [{ category: 'coverage' }],
},
recommendedNextStep: {
title: 'Verify full coverage',
description: 'Run a full Patrol sweep before treating this assessment as an all-clear.',
actionLabel: 'Run Patrol',
actionKind: 'run_patrol',
actionDisabledReason: 'Patrol is already running',
},
});
expect(handoff.prompt).toContain(
'Patrol-owned action "Run Patrol" is currently unavailable: Patrol is already running',
);
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action Status: unavailable - Patrol is already running',
);
expect(handoff.context.context).toMatchObject({
recommendedNextStepActionKind: 'run_patrol',
recommendedNextStepActionDisabledReason: 'Patrol is already running',
});
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Recommended: Run Patrol',
safetyNote:
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled. Run Patrol is currently unavailable: Patrol is already running.',
});
expect(handoff.context.briefing?.detailLines).toEqual(
expect.arrayContaining([
'Recommended next step: Verify full coverage',
'Reason: Run a full Patrol sweep before treating this assessment as an all-clear.',
'Action unavailable: Run Patrol - Patrol is already running',
]),
);
});
it('withholds unsafe recommendation text from assessment handoffs', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
title: 'Coverage incomplete',
description: 'Patrol coverage is incomplete.',
},
overallHealth: {
grade: 'C',
score: 65,
factors: [{ category: 'coverage' }],
},
recommendedNextStep: {
title: 'Run sudo systemctl restart workload.service',
description: 'Use token abc123 before running curl against the host.',
actionLabel: 'sudo restart',
actionKind: 'run_patrol',
},
});
expect(handoff.context.handoffContext).toContain('Recommended Next Step: Run Patrol');
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Detail: sensitive or command detail withheld',
);
expect(handoff.context.handoffContext).toContain(
'Recommended Next Step Action: Run Patrol (run_patrol)',
);
expect(JSON.stringify(handoff)).not.toContain('systemctl');
expect(JSON.stringify(handoff)).not.toContain('abc123');
expect(JSON.stringify(handoff)).not.toContain('curl');
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(handoff.context.handoffContext).not.toContain('Recommended Next Step');
});
it('carries live governed approval posture into assessment finding handoffs', () => {
@@ -739,7 +572,7 @@ describe('patrolInvestigationContextModel', () => {
});
expect(handoff.context.autonomousMode).toBe(false);
expect(handoff.prompt).toContain('Start by reviewing 1 pending governed approval');
expect(handoff.prompt).toContain('The attached context includes 1 pending governed approval');
expect(handoff.prompt).toContain('approval policy, dry-run posture');
expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage');
expect(handoff.context.handoffContext).toContain('approval approval-1');
@@ -781,9 +614,7 @@ describe('patrolInvestigationContextModel', () => {
safetyNote:
'Review approvals in the governed flow; approval policy is attached; dry-run posture is attached; destructive actions remain approval-bound; raw command payloads stay out of Assistant.',
});
expect(handoff.context.briefing?.suggestedPrompts).toContain(
'Review pending approvals and safest next step',
);
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service');
});
@@ -829,7 +660,7 @@ describe('patrolInvestigationContextModel', () => {
const handoff = buildPatrolRunAssistantHandoff(run);
expect(handoff.prompt).toContain('Discuss this Pulse Patrol run');
expect(handoff.prompt).toContain('Start by explaining the Patrol runtime failure');
expect(handoff.prompt).toContain('The attached context includes a Patrol runtime failure');
expect(handoff.prompt).toContain('Provider rejected Patrol tool calls');
expect(handoff.prompt).not.toContain('tool_choice');
expect(handoff.prompt).not.toContain('No endpoints found');
@@ -860,12 +691,8 @@ describe('patrolInvestigationContextModel', () => {
sourceLabel: 'Pulse Patrol',
title: 'Patrol run attached',
actionLabel: 'Review Patrol runtime failure',
suggestedPrompts: [
'Explain why this Patrol run failed',
'List provider or model checks',
'What should I retry after fixing it?',
],
});
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(JSON.stringify(handoff)).not.toContain('provider trace');
expect(JSON.stringify(handoff)).not.toContain('tool_choice');
expect(JSON.stringify(handoff)).not.toContain('No endpoints found');
@@ -913,12 +740,8 @@ describe('patrolInvestigationContextModel', () => {
sourceLabel: 'Pulse Patrol',
title: 'Patrol configuration failure attached',
actionLabel: 'Review Patrol configuration failure',
suggestedPrompts: [
'Explain why Patrol configuration failed',
'List provider or model checks',
'What should I change before retrying?',
],
});
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(JSON.stringify(handoff)).not.toContain('systemctl restart pulse.service');
});
@@ -1148,10 +971,10 @@ describe('patrolInvestigationContextModel', () => {
// Model-owned tool choice — the differentiator vs Explain.
expect(prompt.toLowerCase()).toContain('decide whether the available pulse tools are needed');
expect(prompt.toLowerCase()).toContain('fresh evidence');
// Synthesis instruction — root cause + confidence + safe next step.
// Synthesis instruction — root cause + confidence + model-owned next step.
expect(prompt.toLowerCase()).toContain('root cause');
expect(prompt.toLowerCase()).toContain('confidence');
expect(prompt.toLowerCase()).toContain('safe next step');
expect(prompt.toLowerCase()).toContain('what should happen next');
// Safety: any command-running must route through governed approval,
// not the LLM's own judgment.
expect(prompt.toLowerCase()).toContain('governed approval');
@@ -1256,7 +1079,7 @@ describe('patrolInvestigationContextModel', () => {
started_at: '2026-05-06T12:00:00Z',
},
}),
).toContain('Use that record as the main context before suggesting next actions.');
).toContain('Use that record as context, then decide next steps from current evidence.');
});
it('leads finding prompts with live governed approval context without command text', () => {
@@ -1281,12 +1104,12 @@ describe('patrolInvestigationContextModel', () => {
}),
});
expect(prompt).toContain('Start by reviewing governed approval approval-1');
expect(prompt).toContain('Governed approval approval-1 is attached');
expect(prompt).toContain('approval status pending');
expect(prompt).toContain('high risk');
expect(prompt).toContain('approval policy attached');
expect(prompt).toContain('dry-run posture attached');
expect(prompt).toContain('safest next step');
expect(prompt).toContain('next steps');
expect(prompt).not.toContain('systemctl restart workload.service');
});
@@ -1306,11 +1129,11 @@ describe('patrolInvestigationContextModel', () => {
}),
});
expect(prompt).toContain('Start by reviewing the governed action posture');
expect(prompt).toContain('Governed action posture is attached');
expect(prompt).toContain('medium risk');
expect(prompt).toContain('1 command recorded for approval context');
expect(prompt).toContain('destructive action');
expect(prompt).toContain('safest next step');
expect(prompt).toContain('next steps');
expect(prompt).not.toContain('systemctl restart nginx');
});
@@ -1427,7 +1250,7 @@ describe('patrolInvestigationContextModel', () => {
}),
});
expect(handoff.prompt).toContain('Start by reviewing governed approval approval-1');
expect(handoff.prompt).toContain('Governed approval approval-1 is attached');
expect(handoff.context).toMatchObject({
targetType: 'agent',
targetId: 'agent-1',
@@ -1528,11 +1351,6 @@ describe('patrolInvestigationContextModel', () => {
commandSummary: '1 command recorded for approval context',
safetyNote:
'Command details stay in approval context; destructive actions require governed approval.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain Patrol evidence and confidence',
'Summarize remediation without command text',
],
});
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
vi.useRealTimers();
@@ -1582,7 +1400,7 @@ describe('patrolInvestigationContextModel', () => {
});
expect(prompt).toBe(
'Review this Patrol finding and decide the safest next step: "Nginx down" on node-1.',
'Review this Patrol finding with the attached evidence: "Nginx down" on node-1. Decide what, if anything, should happen next.',
);
expect(modelContext).toContain('[Patrol Finding Action Context]');
expect(modelContext).toContain(
@@ -1677,10 +1495,6 @@ describe('patrolInvestigationContextModel', () => {
resourceId: 'pulse-patrol-runtime',
resourceName: 'Patrol runtime',
resourceType: 'service',
nextStepAction: {
label: 'Open Patrol provider settings',
href: '/settings/system-ai',
},
});
expect(handoff.context).toMatchObject({
@@ -1694,34 +1508,22 @@ describe('patrolInvestigationContextModel', () => {
resourceId: 'pulse-patrol-runtime',
resourceName: 'Patrol runtime',
resourceType: 'service',
nextStepActionLabel: 'Open Patrol provider settings',
nextStepActionHref: '/settings/system-ai',
actionReferenceCount: 0,
},
});
expect(handoff.prompt).toContain(
'Patrol\'s visible next step is "Open Patrol provider settings"',
);
expect(handoff.prompt).not.toContain('visible Patrol navigation');
expect(handoff.context.briefing).toMatchObject({
actionLabel: 'Open Patrol provider settings',
actionHref: '/settings/system-ai',
suggestedPrompts: [
'Review Patrol next step',
'Explain current Patrol loop state',
'Check prerequisites before next step',
],
title: 'Operator briefing attached',
subject: 'Provider connection issue on Patrol runtime',
});
expect(handoff.context.handoffMetadata).toMatchObject({
expect(handoff.context.briefing?.actionLabel).toBeUndefined();
expect(handoff.context.briefing?.actionHref).toBeUndefined();
expect(handoff.context.briefing?.suggestedPrompts).toBeUndefined();
expect(handoff.context.handoffMetadata).toEqual({
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
});
expect(handoff.context.handoffActions).toBeUndefined();
expect(handoff.context.handoffContext).toContain(
'Patrol Next Step: Open Patrol provider settings',
);
expect(handoff.context.handoffContext).toContain('Patrol Next Step Route: /settings/system-ai');
expect(handoff.context.handoffContext).not.toContain('Patrol Next Step');
expect(handoff.context.handoffContext).toContain(
'Operator Boundary: This Patrol finding handoff is model-only context',
);
@@ -1750,11 +1552,6 @@ describe('patrolInvestigationContextModel', () => {
actionLabel: undefined,
commandSummary: undefined,
safetyNote: undefined,
suggestedPrompts: [
'Explain current finding status',
'Explain current Patrol loop state',
'Explain recurrence and what changed',
],
});
});
@@ -1787,11 +1584,6 @@ describe('patrolInvestigationContextModel', () => {
actionLabel: 'Approval approval-1',
commandSummary: undefined,
safetyNote: 'Execution requires the governed approval flow.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'List approval prerequisites before action',
],
});
});
@@ -1817,11 +1609,6 @@ describe('patrolInvestigationContextModel', () => {
actionLabel: undefined,
commandSummary: undefined,
safetyNote: undefined,
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'List approval prerequisites before action',
],
});
});
@@ -1857,11 +1644,6 @@ describe('patrolInvestigationContextModel', () => {
commandSummary: '1 command recorded for approval context',
safetyNote:
'Command details stay in approval context; destructive actions require governed approval.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'Summarize remediation without command text',
],
});
});
});
@@ -116,7 +116,6 @@ export interface PatrolAssistantFindingPromptInput {
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
nextStepAction?: PatrolAssistantNextStepInput | null;
intent?: PatrolAssistantFindingIntent;
}
@@ -145,11 +144,6 @@ export interface PatrolAssistantProposedFixBriefingInput {
destructive?: boolean | null;
}
export interface PatrolAssistantNextStepInput {
label?: string | null;
href?: string | null;
}
export interface PatrolAssistantProposedFixBriefingSource {
description?: string | null;
riskLevel?: string | null;
@@ -176,7 +170,6 @@ export interface PatrolAssistantFindingBriefingInput {
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
nextStepAction?: PatrolAssistantNextStepInput | null;
}
export interface PatrolAssistantFindingHandoffInput {
@@ -201,7 +194,6 @@ export interface PatrolAssistantFindingHandoffInput {
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
nextStepAction?: PatrolAssistantNextStepInput | null;
intent?: PatrolAssistantFindingIntent;
}
@@ -240,21 +232,6 @@ export interface PatrolAssessmentAssistantFindingInput {
investigationRecord?: InvestigationRecord | null;
}
export type PatrolAssessmentRecommendedNextStepActionKind =
| 'discuss_assessment'
| 'open_provider_settings'
| 'review_approvals'
| 'review_findings'
| 'run_patrol';
export interface PatrolAssessmentRecommendedNextStepInput {
title?: string | null;
description?: string | null;
actionLabel?: string | null;
actionKind?: PatrolAssessmentRecommendedNextStepActionKind | string | null;
actionDisabledReason?: string | null;
}
export interface PatrolAssessmentAssistantHandoffInput {
assessment?: {
title?: string | null;
@@ -300,7 +277,6 @@ export interface PatrolAssessmentAssistantHandoffInput {
recentChanges?: ResourceChange[] | null;
correlations?: ResourceCorrelation[] | null;
} | null;
recommendedNextStep?: PatrolAssessmentRecommendedNextStepInput | null;
activeFindings?: PatrolAssessmentAssistantFindingInput[] | null;
}
@@ -352,7 +328,6 @@ const MAX_ASSESSMENT_CORRELATIONS = 3;
const MAX_ASSESSMENT_RESOURCES = 8;
const MAX_ASSESSMENT_HANDOFF_ACTIONS = 4;
const MAX_PATROL_RUN_HANDOFF_RESOURCES = 8;
const MAX_PATROL_BRIEFING_SUGGESTED_PROMPTS = 3;
const MAX_ASSESSMENT_RELATED_RESOURCE_LABELS = 4;
const MAX_ASSESSMENT_RELATED_RESOURCE_LABEL_LENGTH = 80;
const SAME_STATE_CHANGED_FIELD_LABELS: Record<string, string> = {
@@ -369,27 +344,6 @@ const SAME_STATE_CHANGED_FIELD_LABELS: Record<string, string> = {
identity: 'identity',
};
interface NormalizedPatrolAssessmentRecommendedNextStep {
title: string;
description?: string;
actionLabel?: string;
actionKind?: PatrolAssessmentRecommendedNextStepActionKind;
actionDisabledReason?: string;
actionSummary?: string;
}
const PATROL_ASSESSMENT_RECOMMENDED_NEXT_STEP_ACTION_LABELS: Record<
PatrolAssessmentRecommendedNextStepActionKind,
string
> = {
discuss_assessment: 'Discuss with Assistant',
open_provider_settings: 'Open provider settings',
review_approvals: 'Review approvals',
review_findings: 'Review findings',
run_patrol: 'Run Patrol',
};
const WITHHELD_RECOMMENDATION_TEXT = 'sensitive or command detail withheld';
export function buildPatrolInvestigationContextSummary(
input: PatrolInvestigationContextSummaryInput,
): PatrolInvestigationContextSummary {
@@ -535,14 +489,13 @@ export function buildPatrolAssistantFindingPrompt(
const description = normalizeText(input.description);
const hasRecord = Boolean(input.investigationRecord?.id);
const actionInstruction = buildPatrolAssistantFindingActionPromptInstruction(input);
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
let prompt: string;
if (input.intent === 'explain') {
prompt =
`Explain this Patrol finding: "${title}" on ${subject}. ` +
'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. ' +
'how confident the analysis is, what remains uncertain, and what you would do next. ' +
'Use the structured investigation record and operational memory in the attached context as the primary source.';
} else if (input.intent === 'investigate') {
prompt =
@@ -550,7 +503,7 @@ export function buildPatrolAssistantFindingPrompt(
'Use the attached context and decide whether the available Pulse tools are needed to gather fresh evidence ' +
'about the current state of the affected resource and related resources. ' +
'Then synthesize: what is the root cause given the current evidence, ' +
'what is your confidence, what is the safe next step, and is the recommended action still right? ' +
'what is your confidence, and what should happen next? ' +
'If any command-running step is involved, surface it for governed approval rather than executing on your own judgment.';
} else if (input.intent === 'why') {
prompt =
@@ -582,14 +535,11 @@ export function buildPatrolAssistantFindingPrompt(
input.intent !== 'verify_fix'
) {
prompt +=
'\n\nPulse Patrol has a structured investigation record for this finding. Use that record as the main context before suggesting next actions.';
'\n\nPulse Patrol has a structured investigation record for this finding. Use that record as context, then decide next steps from current evidence.';
}
if (actionInstruction) {
prompt += `\n\n${actionInstruction}`;
}
if (nextStepAction.label) {
prompt += `\n\nPatrol's visible next step is "${nextStepAction.label}". Treat it as operator guidance for review, not an execution command.`;
}
if (description) {
prompt += `\n\n${description}`;
}
@@ -619,9 +569,9 @@ function buildPatrolAssistantFindingActionPromptInstruction(
: undefined,
].filter(isNonEmptyString);
return `Start by reviewing governed approval ${pendingApproval.id}${
return `Governed approval ${pendingApproval.id} is attached${
contextParts.length > 0 ? ` (${contextParts.join('; ')})` : ''
}. Use the attached Patrol context to explain prerequisites, risk, and the safest next step before any execution.`;
}. Treat it as approval context. Use the attached Patrol evidence to decide prerequisites, risk, and next steps before any execution.`;
}
const hasGovernedActionPosture = Boolean(
@@ -648,9 +598,9 @@ function buildPatrolAssistantFindingActionPromptInstruction(
outcome ? `outcome ${formatIdentifierLabel(outcome)?.toLowerCase() || outcome}` : undefined,
].filter(isNonEmptyString);
return `Start by reviewing the governed action posture${
return `Governed action posture is attached${
contextParts.length > 0 ? ` (${contextParts.join('; ')})` : ''
}. Use the attached Patrol context to explain risk, prerequisites, and the safest next step without repeating command text.`;
}. Treat it as approval context, not a remediation decision. Use the attached Patrol evidence to decide risk, prerequisites, and next steps without repeating command text.`;
}
export function buildPatrolAssessmentAssistantHandoff(
@@ -658,9 +608,6 @@ export function buildPatrolAssessmentAssistantHandoff(
): PatrolAssessmentAssistantHandoff {
const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment';
const description = normalizeText(input.assessment?.description);
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const recommendedNextStepActionHref =
getAssessmentRecommendedNextStepActionHref(recommendedNextStep);
const handoffContext = buildPatrolAssessmentAssistantModelContext(input);
const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges);
const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations);
@@ -677,11 +624,6 @@ export function buildPatrolAssessmentAssistantHandoff(
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
handoffMetadata: {
kind: 'patrol_assessment',
recommendedNextStep: recommendedNextStep?.title,
recommendedNextStepDetail: recommendedNextStep?.description,
recommendedNextStepAction: recommendedNextStep?.actionLabel,
recommendedNextStepActionKind: recommendedNextStep?.actionKind,
recommendedNextStepActionHref,
},
briefing: buildPatrolAssessmentAssistantBriefing(input),
context: {
@@ -693,13 +635,6 @@ export function buildPatrolAssessmentAssistantHandoff(
correlationDetailCount: correlations.length,
governedResourceCount: input.investigationContext?.governedResourceCount ?? 0,
pendingApprovalCount: normalizeAssessmentPendingApprovalCount(input.activeFindings),
...(recommendedNextStep
? {
recommendedNextStepTitle: recommendedNextStep.title,
recommendedNextStepActionKind: recommendedNextStep.actionKind,
recommendedNextStepActionDisabledReason: recommendedNextStep.actionDisabledReason,
}
: {}),
},
},
};
@@ -712,7 +647,6 @@ export function buildPatrolAssistantFindingHandoff(
const resource = buildPatrolFindingHandoffResource(input);
const handoffResources = resource ? [resource] : [];
const handoffActions = buildPatrolAssistantFindingHandoffActions(input);
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
return {
prompt: buildPatrolAssistantFindingPrompt({
@@ -724,7 +658,6 @@ export function buildPatrolAssistantFindingHandoff(
pendingApproval: input.pendingApproval,
proposedFix: input.proposedFix,
investigationRecord: input.investigationRecord,
nextStepAction: input.nextStepAction,
intent: input.intent,
}),
context: {
@@ -735,14 +668,9 @@ export function buildPatrolAssistantFindingHandoff(
handoffContext: buildPatrolAssistantFindingModelContext(input),
handoffResources: handoffResources.length > 0 ? handoffResources : undefined,
handoffActions: handoffActions.length > 0 ? handoffActions : undefined,
handoffMetadata: nextStepAction.label
? {
kind: 'patrol_finding',
recommendedNextStep: nextStepAction.label,
recommendedNextStepAction: nextStepAction.label,
recommendedNextStepActionHref: nextStepAction.href || undefined,
}
: undefined,
handoffMetadata: {
kind: 'patrol_finding',
},
briefing: buildPatrolAssistantFindingBriefing({
title: input.title,
subject: input.subject,
@@ -757,7 +685,6 @@ export function buildPatrolAssistantFindingHandoff(
pendingApproval: input.pendingApproval,
proposedFix: input.proposedFix,
investigationRecord: input.investigationRecord,
nextStepAction: input.nextStepAction,
}),
context: {
source: 'pulse-patrol-finding',
@@ -767,8 +694,6 @@ export function buildPatrolAssistantFindingHandoff(
resourceName: resource?.name,
resourceType: resource?.type,
pendingApprovalId: normalizeApprovalBriefing(input.pendingApproval).id || undefined,
nextStepActionLabel: nextStepAction.label || undefined,
nextStepActionHref: nextStepAction.href || undefined,
actionReferenceCount: handoffActions.length,
},
},
@@ -859,11 +784,6 @@ export function buildPatrolConfigurationFailureHandoff(
actionLabel: `Review ${issueLabel}`,
safetyNote:
'Assistant can explain the configuration state; provider changes, retries, and remediation remain operator-controlled.',
suggestedPrompts: formatPatrolSuggestedPrompts([
'Explain why Patrol configuration failed',
'List provider or model checks',
'What should I change before retrying?',
]),
},
context: {
source: 'pulse-patrol-configuration-failure',
@@ -892,14 +812,13 @@ function buildPatrolAssessmentAssistantPrompt(
description: string,
handoffActions: AIChatHandoffAction[],
): string {
const recommendationInstruction = buildPatrolAssessmentRecommendationPromptInstruction(input);
const reviewInstruction = buildPatrolAssessmentReviewPromptInstruction(input, handoffActions);
return [
`Discuss the current Pulse Patrol assessment: ${title}.`,
description,
reviewInstruction,
recommendationInstruction,
'Use the attached model-only assessment context and decide what, if anything, needs fresh evidence, operator action, or governed tool use.',
'Do not infer, repeat, or execute raw command text from this handoff.',
]
.filter(isNonEmptyString)
@@ -916,31 +835,31 @@ function buildPatrolAssessmentReviewPromptInstruction(
const hasCoverageOnlyGap = assessmentHasCoverageGap(input) && activeFindingCount === 0;
if (pendingApprovalCount > 0) {
return `Start by reviewing ${formatAssessmentMetricCount(
return `The attached context includes ${formatAssessmentMetricCount(
'pending governed approvals',
pendingApprovalCount,
)}, approval policy, dry-run posture, and the safest next step from the attached context.`;
)}, approval policy, dry-run posture, and current evidence.`;
}
if (actionCount > 0) {
return `Start by reviewing ${formatAssessmentMetricCount(
return `The attached context includes ${formatAssessmentMetricCount(
'governed action references',
actionCount,
)}, risk, and the safest next step from the attached context.`;
)}, risk, and current evidence.`;
}
if (activeFindingCount > 0) {
return `Start by prioritizing ${formatAssessmentMetricCount(
return `The attached context includes ${formatAssessmentMetricCount(
'active findings',
activeFindingCount,
)}, affected resources, evidence, verification caveats, and the safest next step from the attached context.`;
)}, affected resources, evidence, and verification caveats.`;
}
if (hasCoverageOnlyGap) {
return 'Start by explaining why Patrol coverage is incomplete, what the latest scoped activity did and did not prove, and whether a full Patrol verification should run before action.';
return 'The attached context includes incomplete Patrol coverage signals. Decide from the evidence whether more verification is needed before action.';
}
return 'Use the attached model-only Patrol assessment context before suggesting next actions. Help me understand priority, risk, and safe next steps.';
return 'Review the attached model-only Patrol assessment context.';
}
function buildPatrolAssessmentAssistantBriefing(
@@ -953,9 +872,6 @@ function buildPatrolAssessmentAssistantBriefing(
const verification = formatAssessmentVerification(input);
const latestRun = formatAssessmentLatestRun(input);
const contextSummary = normalizeText(input.investigationContext?.summaryText);
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const recommendedNextStepLines =
formatAssessmentRecommendedNextStepDetailLines(recommendedNextStep);
const findings = normalizeAssessmentFindings(input.activeFindings);
const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges);
const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations);
@@ -974,100 +890,16 @@ function buildPatrolAssessmentAssistantBriefing(
title: 'Patrol assessment attached',
subject: title,
statusLabel: [health, attentionSummary].filter(isNonEmptyString).join(' · ') || undefined,
detailLines: [description, ...recommendedNextStepLines, verification, latestRun, contextSummary]
detailLines: [description, verification, latestRun, contextSummary]
.filter(isNonEmptyString)
.slice(0, 7),
evidence: [...findingEvidence.slice(0, 3), ...supportingEvidence].slice(0, 5),
actionLabel: actionPosture.actionLabel,
actionHref: actionPosture.actionHref,
safetyNote: actionPosture.safetyNote,
suggestedPrompts: buildPatrolAssessmentSuggestedPrompts(input, {
findings,
recentChanges,
correlations,
}),
};
}
function buildPatrolAssessmentRecommendationPromptInstruction(
input: PatrolAssessmentAssistantHandoffInput,
): string | undefined {
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
if (!recommendedNextStep) return undefined;
const parts = [
`Patrol's visible recommended next step is "${recommendedNextStep.title}"`,
recommendedNextStep.description
? `detail: ${truncateContextText(recommendedNextStep.description, 180)}`
: undefined,
formatAssessmentRecommendedNextStepActionInstruction(recommendedNextStep),
].filter(isNonEmptyString);
return `${parts.join('; ')}. Explain that recommendation before alternatives, but keep Patrol runs, settings changes, diagnostics, remediation, and approvals in governed controls.`;
}
function formatAssessmentRecommendedNextStepActionInstruction(
recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep.actionLabel) return undefined;
if (recommendedNextStep.actionDisabledReason) {
return `Patrol-owned action "${recommendedNextStep.actionLabel}" is currently unavailable: ${recommendedNextStep.actionDisabledReason}`;
}
return `available Patrol-owned action: ${recommendedNextStep.actionLabel}`;
}
function formatAssessmentRecommendedNextStepActionBriefingDetail(
recommendedNextStep: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep.actionLabel) return undefined;
return recommendedNextStep.actionDisabledReason
? `Action unavailable: ${recommendedNextStep.actionLabel} - ${recommendedNextStep.actionDisabledReason}`
: `Available action: ${recommendedNextStep.actionLabel}`;
}
function formatAssessmentRecommendedNextStepActionAvailability(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
if (!recommendedNextStep?.actionDisabledReason) return undefined;
return `unavailable - ${recommendedNextStep.actionDisabledReason}`;
}
function formatAssessmentRecommendationSafetyNote(
base: string,
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string {
if (!recommendedNextStep?.actionLabel || !recommendedNextStep.actionDisabledReason) {
return base;
}
return `${base} ${recommendedNextStep.actionLabel} is currently unavailable: ${recommendedNextStep.actionDisabledReason}.`;
}
function formatAssessmentRecommendedNextStepDetailLines(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string[] {
if (!recommendedNextStep) return [];
return [
`Recommended next step: ${recommendedNextStep.title}`,
recommendedNextStep.description ? `Reason: ${recommendedNextStep.description}` : undefined,
formatAssessmentRecommendedNextStepActionBriefingDetail(recommendedNextStep),
].filter(isNonEmptyString);
}
function getAssessmentRecommendedNextStepActionHref(
recommendedNextStep?: NormalizedPatrolAssessmentRecommendedNextStep,
): string | undefined {
switch (recommendedNextStep?.actionKind) {
case 'open_provider_settings':
return '/settings/system-ai';
case 'review_approvals':
case 'review_findings':
return '/patrol';
default:
return undefined;
}
}
function buildPatrolAssessmentActionPosture(
input: PatrolAssessmentAssistantHandoffInput,
handoffActions: AIChatHandoffAction[],
@@ -1077,9 +909,6 @@ function buildPatrolAssessmentActionPosture(
const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
const hasCoverageGap = assessmentHasCoverageGap(input);
const hasCoverageOnlyGap = hasCoverageGap && activeFindingCount === 0;
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const recommendedNextStepActionHref =
getAssessmentRecommendedNextStepActionHref(recommendedNextStep);
const hasDryRunPosture = handoffActions.some((action) =>
Boolean(normalizeText(action.actionDryRunSummary) || normalizeText(action.actionPreflight)),
);
@@ -1120,25 +949,9 @@ function buildPatrolAssessmentActionPosture(
if (hasCoverageOnlyGap) {
return {
actionLabel: recommendedNextStep?.actionLabel
? `Recommended: ${recommendedNextStep.actionLabel}`
: 'Review coverage gap',
actionHref: recommendedNextStep?.actionLabel ? recommendedNextStepActionHref : undefined,
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the gap; full Patrol runs, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
),
};
}
if (recommendedNextStep?.actionLabel || recommendedNextStep?.title) {
return {
actionLabel: `Recommended: ${recommendedNextStep.actionLabel || recommendedNextStep.title}`,
actionHref: recommendedNextStep.actionLabel ? recommendedNextStepActionHref : undefined,
safetyNote: formatAssessmentRecommendationSafetyNote(
'Assistant can explain the Patrol recommendation; Patrol runs, settings changes, diagnostics, and remediation remain operator-controlled.',
recommendedNextStep,
),
actionLabel: 'Discuss Patrol coverage',
safetyNote:
'Assistant can review the coverage evidence; Patrol runs, diagnostics, and remediation remain governed controls.',
};
}
@@ -1168,72 +981,6 @@ function formatAssessmentActionSafetyNote(input: {
return `${parts.join('; ')}.`;
}
function buildPatrolAssessmentSuggestedPrompts(
input: PatrolAssessmentAssistantHandoffInput,
normalized: {
findings: PatrolAssessmentAssistantFindingInput[];
recentChanges: ResourceChange[];
correlations: ResourceCorrelation[];
},
): string[] {
const prompts: string[] = [];
const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length);
const hasCoverageGap = assessmentHasCoverageGap(input);
const hasCoverageOnlyGap = hasCoverageGap && activeFindingCount === 0;
const hasSupportingEvidence =
normalized.recentChanges.length > 0 ||
normalized.correlations.length > 0 ||
input.investigationContext?.hasContext === true;
const hasGovernedAction = normalized.findings.some(assessmentFindingHasGovernedAction);
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
if (recommendedNextStep?.actionKind === 'open_provider_settings') {
return formatPatrolSuggestedPrompts([
'Explain why Patrol visibility is blocked',
'What should I check in provider settings?',
'What should I verify after restoring Patrol?',
]);
}
if (activeFindingCount > 0) {
prompts.push('Prioritize findings and safest next step');
} else if (hasCoverageOnlyGap) {
prompts.push('Explain why coverage is incomplete');
} else {
prompts.push('Explain current health and what to watch');
}
if (hasCoverageOnlyGap) {
prompts.push(
input.verification?.activityMixLabel || input.latestRun?.kindLabel
? 'Explain scoped activity and full-run gap'
: 'What should a full Patrol verify next?',
);
} else if (hasSupportingEvidence) {
prompts.push('Explain recent changes and correlations');
}
if (hasGovernedAction) {
prompts.push(
normalizeAssessmentPendingApprovalCount(input.activeFindings) > 0
? 'Review pending approvals and safest next step'
: 'Summarize governed remediation risks',
);
} else if (activeFindingCount > 0) {
prompts.push('List evidence to verify before action');
} else if (hasCoverageOnlyGap) {
prompts.push(
hasSupportingEvidence
? 'Identify early warning signals before full verification'
: 'What should a full Patrol verify next?',
);
} else if (hasSupportingEvidence) {
prompts.push('Identify early warning signals');
}
return formatPatrolSuggestedPrompts(prompts);
}
function assessmentHasCoverageGap(input: PatrolAssessmentAssistantHandoffInput): boolean {
const title = normalizeText(input.assessment?.title).toLowerCase();
const description = normalizeText(input.assessment?.description).toLowerCase();
@@ -1254,31 +1001,9 @@ function assessmentHasCoverageGap(input: PatrolAssessmentAssistantHandoffInput):
);
}
function assessmentFindingHasGovernedAction(
finding: PatrolAssessmentAssistantFindingInput,
): boolean {
if (
patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: finding.investigationOutcome,
pendingApproval: finding.pendingApproval,
investigationRecord: finding.investigationRecord,
})
) {
return true;
}
if (normalizeProposedFixBriefing(finding.proposedFix)) {
return true;
}
const loopState = normalizeText(finding.loopState).toLowerCase();
return loopState.includes('approval') || loopState.includes('remediation');
}
function buildPatrolAssessmentAssistantModelContext(
input: PatrolAssessmentAssistantHandoffInput,
): string {
const recommendedNextStep = normalizeAssessmentRecommendedNextStep(input.recommendedNextStep);
const findings = normalizeAssessmentFindings(input.activeFindings);
const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges);
const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations);
@@ -1306,13 +1031,6 @@ function buildPatrolAssessmentAssistantModelContext(
formatContextLine('Assessment Scope', input.assessment?.eyebrow),
formatContextLine('Health', formatAssessmentHealth(input)),
formatContextLine('Attention', formatAssessmentAttentionSummary(input)),
formatContextLine('Recommended Next Step', recommendedNextStep?.title),
formatContextLine('Recommended Next Step Detail', recommendedNextStep?.description),
formatContextLine('Recommended Next Step Action', recommendedNextStep?.actionSummary),
formatContextLine(
'Recommended Next Step Action Status',
formatAssessmentRecommendedNextStepActionAvailability(recommendedNextStep),
),
formatContextLine('Verification', formatAssessmentVerification(input)),
formatContextLine('Last Patrol', formatAssessmentRecency(input)),
formatContextLine('Latest Run', formatAssessmentLatestRun(input)),
@@ -1353,15 +1071,15 @@ function buildPatrolRunAssistantPrompt(
const trigger = formatTriggerReason(run.trigger_reason);
const runLabel = [kindLabel, runId].filter(isNonEmptyString).join(' ') || 'Patrol run';
const focus = runtimeFailure
? `Start by explaining the Patrol runtime failure (${truncateContextText(runtimeFailure, 180)}), what likely caused it, and what should be checked before retrying Patrol.`
: `Start by explaining the run outcome (${statusLabel}${
? `The attached context includes a Patrol runtime failure: ${truncateContextText(runtimeFailure, 180)}.`
: `The attached context includes a Patrol run outcome: ${statusLabel}${
trigger ? `, ${trigger.toLowerCase()}` : ''
}) and the safest next operational step from the attached context.`;
}.`;
return [
`Discuss this Pulse Patrol run: ${runLabel}.`,
focus,
'Use the attached model-only run history context before suggesting next actions.',
'Use the attached model-only run history context and decide what, if anything, needs fresh evidence, operator action, or governed tool use.',
'Do not infer, repeat, or execute raw command text from this handoff.',
]
.filter(isNonEmptyString)
@@ -1399,26 +1117,9 @@ function buildPatrolRunAssistantBriefing(
actionLabel: runtimeFailure ? 'Review Patrol runtime failure' : 'Discuss Patrol run outcome',
safetyNote:
'Assistant can explain the Patrol run context; retries, configuration changes, and remediation remain operator-controlled.',
suggestedPrompts: buildPatrolRunSuggestedPrompts(Boolean(runtimeFailure)),
};
}
function buildPatrolRunSuggestedPrompts(hasRuntimeFailure: boolean): string[] {
if (hasRuntimeFailure) {
return formatPatrolSuggestedPrompts([
'Explain why this Patrol run failed',
'List provider or model checks',
'What should I retry after fixing it?',
]);
}
return formatPatrolSuggestedPrompts([
'Summarize this Patrol run',
'What needs attention from this run?',
'What should I verify next?',
]);
}
function buildPatrolConfigurationFailurePrompt(
input: PatrolConfigurationFailureInput,
message: string,
@@ -1429,9 +1130,9 @@ function buildPatrolConfigurationFailurePrompt(
return [
`Discuss this Pulse Patrol ${issueLabel}.`,
code ? `Server code: ${code}.` : undefined,
`Start by explaining this failure: ${truncateContextText(message, 220)}.`,
`The attached context includes this failure: ${truncateContextText(message, 220)}.`,
detailLines.length > 0 ? `Attached details: ${detailLines.join('; ')}.` : undefined,
'Use the attached model-only configuration context before suggesting next actions.',
'Use the attached model-only configuration context and decide what, if anything, needs fresh evidence, operator action, or governed tool use.',
'Do not infer, repeat, or execute raw command text from this handoff.',
]
.filter(isNonEmptyString)
@@ -2304,7 +2005,6 @@ function buildPatrolAssistantFindingModelContext(
? `last regression ${formatBriefingTimestamp(normalizeText(input.lastRegressionAt))}`
: undefined,
].filter(isNonEmptyString);
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
const attentionReason = buildPatrolAssistantAttentionReason(input, record);
const operatorDecision = buildPatrolAssistantOperatorDecision(input);
const proposedFixFacts = proposedFix
@@ -2372,8 +2072,6 @@ function buildPatrolAssistantFindingModelContext(
formatContextLine('Action Preflight', pendingApproval.actionPreflight),
formatContextLine('Dry-Run Posture', pendingApproval.actionDryRunSummary),
formatContextLine('Proposed Fix', proposedFixFacts),
formatContextLine('Patrol Next Step', nextStepAction.label),
formatContextLine('Patrol Next Step Route', nextStepAction.href),
formatContextLine('Operator Decision', operatorDecision),
'Command Boundary: Command details stay in governed approval or remediation context; this model-only handoff may include command counts but not raw command text.',
'Operator Boundary: This Patrol finding handoff is model-only context for explanation and review. Diagnostics, remediation, and command execution require explicit governed approval.',
@@ -2468,7 +2166,7 @@ export function buildPatrolRemediationPlanAssistantPrompt(
const title = normalizeText(input.title) || 'Patrol finding';
const subject = normalizeText(input.subject) || 'the affected resource';
return `Review this Patrol finding and decide the safest next step: "${title}" on ${subject}.`;
return `Review this Patrol finding with the attached evidence: "${title}" on ${subject}. Decide what, if anything, should happen next.`;
}
export function buildPatrolRemediationPlanAssistantModelContext(
@@ -2485,7 +2183,7 @@ export function buildPatrolRemediationPlanAssistantModelContext(
return [
'[Patrol Finding Action Context]',
'Pulse is attaching observed finding context and any existing governed action artifact. The selected language model should decide whether remediation is appropriate and propose the safest next step.',
'Pulse is attaching observed finding context and any existing governed action artifact. The selected language model should decide whether remediation is appropriate and what should happen next.',
formatContextLine('Finding', `${title} on ${subject}`),
formatContextLine('Existing Action Artifact', planTitle),
formatContextLine('Artifact Status', statusLabel),
@@ -2540,7 +2238,6 @@ export function buildPatrolAssistantFindingBriefing(
const subject = normalizeText(input.subject) || 'affected resource';
const pendingApproval = normalizeApprovalBriefing(input.pendingApproval);
const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix);
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
const approvalStatusParts = !record.hasRecord
? [
pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '',
@@ -2577,7 +2274,6 @@ export function buildPatrolAssistantFindingBriefing(
const actionLabel =
proposedFix?.description ||
(pendingApproval.id ? `Approval ${pendingApproval.id}` : undefined) ||
nextStepAction.label ||
undefined;
return {
@@ -2588,74 +2284,11 @@ export function buildPatrolAssistantFindingBriefing(
detailLines,
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
actionLabel,
actionHref: actionLabel === nextStepAction.label ? nextStepAction.href || undefined : undefined,
commandSummary: proposedFix?.commandSummary,
safetyNote: buildPatrolAssistantSafetyNote(proposedFix, pendingApproval),
suggestedPrompts: buildPatrolFindingSuggestedPrompts(
input,
record,
pendingApproval,
proposedFix,
),
};
}
function buildPatrolFindingSuggestedPrompts(
input: PatrolAssistantFindingBriefingInput,
record: PatrolInvestigationRecordPresentation,
pendingApproval: Required<PatrolAssistantApprovalBriefingInput>,
proposedFix?: PatrolInvestigationRecordPresentation['proposedFix'],
): string[] {
const prompts: string[] = [];
const requiresApproval = patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: input.investigationOutcome || input.investigationRecord?.outcome,
remediationId: input.remediationId,
pendingApproval,
investigationRecord: input.investigationRecord,
});
const hasLoopState = Boolean(normalizeText(input.loopState));
const hasRecurrence =
normalizeNonNegativeCount(input.regressionCount) > 0 ||
normalizeNonNegativeCount(input.timesRaised) > 1;
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
if (requiresApproval) {
prompts.push('Review approval risk and next step');
} else if (record.hasRecord) {
prompts.push('Prioritize finding and safest next step');
} else if (nextStepAction.label) {
prompts.push('Review Patrol next step');
} else {
prompts.push('Explain current finding status');
}
if (record.hasRecord) {
prompts.push('Explain Patrol evidence and confidence');
} else if (requiresApproval) {
prompts.push('Explain current finding status');
} else if (hasLoopState) {
prompts.push('Explain current Patrol loop state');
} else {
prompts.push('List evidence to gather before action');
}
if (proposedFix?.commandSummary) {
prompts.push('Summarize remediation without command text');
} else if (requiresApproval) {
prompts.push('List approval prerequisites before action');
} else if (nextStepAction.label) {
prompts.push('Check prerequisites before next step');
} else if (hasRecurrence) {
prompts.push('Explain recurrence and what changed');
} else if (hasLoopState) {
prompts.push('Explain current Patrol loop state');
} else {
prompts.push('List evidence to gather before action');
}
return formatPatrolSuggestedPrompts(prompts);
}
function buildPatrolAssistantAttentionReason(
input: PatrolAssistantFindingBriefingInput,
record: PatrolInvestigationRecordPresentation,
@@ -2882,10 +2515,6 @@ function buildPatrolAssistantOperatorDecision(
if (loopState.includes('investigat')) {
return 'Wait for Patrol to finish the investigation before approving remediation.';
}
const nextStepAction = normalizePatrolAssistantNextStepAction(input.nextStepAction);
if (nextStepAction.label) {
return `Use Patrol's next step: ${nextStepAction.label}; review the finding context before changing settings or rerunning Patrol.`;
}
if (normalizeText(input.findingStatus).toLowerCase() === 'active') {
return 'Continue investigation or monitoring; no governed action reference is ready.';
}
@@ -2943,15 +2572,6 @@ function normalizeProposedFixBriefing(
return normalized;
}
function normalizePatrolAssistantNextStepAction(
action?: PatrolAssistantNextStepInput | null,
): Required<PatrolAssistantNextStepInput> {
return {
label: normalizeText(action?.label),
href: normalizeText(action?.href),
};
}
function formatPatrolAssistantProposedFixDetail(
proposedFix?: PatrolInvestigationRecordPresentation['proposedFix'],
): string | undefined {
@@ -3039,94 +2659,6 @@ function formatPlanCommandSummary(plan: RemediationPlan): string | undefined {
return parts.join('; ');
}
function formatPatrolSuggestedPrompts(values: string[]): string[] {
return Array.from(new Set(values.map(normalizeText).filter(isNonEmptyString))).slice(
0,
MAX_PATROL_BRIEFING_SUGGESTED_PROMPTS,
);
}
function normalizeAssessmentRecommendedNextStep(
input?: PatrolAssessmentRecommendedNextStepInput | null,
): NormalizedPatrolAssessmentRecommendedNextStep | undefined {
if (!input) return undefined;
const actionKind = normalizeAssessmentRecommendedNextStepActionKind(input.actionKind);
const title = formatSafeAssessmentRecommendationText(input.title, 140);
const description = formatSafeAssessmentRecommendationText(input.description, 260);
const fallbackActionLabel = actionKind
? PATROL_ASSESSMENT_RECOMMENDED_NEXT_STEP_ACTION_LABELS[actionKind]
: undefined;
const safeActionLabel = formatSafeAssessmentRecommendationText(input.actionLabel, 80);
const actionLabel =
safeActionLabel === WITHHELD_RECOMMENDATION_TEXT && fallbackActionLabel
? fallbackActionLabel
: safeActionLabel || fallbackActionLabel;
const actionDisabledReason = formatSafeAssessmentRecommendationText(
input.actionDisabledReason,
140,
);
const effectiveTitle =
title === WITHHELD_RECOMMENDATION_TEXT && actionLabel ? actionLabel : title || actionLabel;
const actionSummary = formatAssessmentRecommendedNextStepActionSummary(actionLabel, actionKind);
if (!effectiveTitle && !description && !actionSummary) {
return undefined;
}
return {
title: effectiveTitle || 'Review Patrol recommendation',
description,
actionLabel,
actionKind,
actionDisabledReason,
actionSummary,
};
}
function normalizeAssessmentRecommendedNextStepActionKind(
value?: string | null,
): PatrolAssessmentRecommendedNextStepActionKind | undefined {
const normalized = normalizeText(value).toLowerCase();
if (!normalized) return undefined;
return Object.prototype.hasOwnProperty.call(
PATROL_ASSESSMENT_RECOMMENDED_NEXT_STEP_ACTION_LABELS,
normalized,
)
? (normalized as PatrolAssessmentRecommendedNextStepActionKind)
: undefined;
}
function formatAssessmentRecommendedNextStepActionSummary(
actionLabel?: string,
actionKind?: PatrolAssessmentRecommendedNextStepActionKind,
): string | undefined {
if (actionLabel && actionKind) return `${actionLabel} (${actionKind})`;
if (actionLabel) return actionLabel;
if (actionKind) return PATROL_ASSESSMENT_RECOMMENDED_NEXT_STEP_ACTION_LABELS[actionKind];
return undefined;
}
function formatSafeAssessmentRecommendationText(
value?: string | null,
limit: number = 240,
): string | undefined {
const normalized = truncateContextText(value, limit);
if (!normalized) return undefined;
if (assessmentRecommendationTextShouldBeWithheld(normalized)) {
return WITHHELD_RECOMMENDATION_TEXT;
}
return normalized;
}
function assessmentRecommendationTextShouldBeWithheld(value: string): boolean {
return (
/(password|secret|token|api[_-]?key|credential|private[_-]?key)/i.test(value) ||
/\b(systemctl|sudo|bash|sh\s+-c|curl|wget|kubectl)\b/i.test(value) ||
/\b(docker|ssh)\s+\S+/i.test(value)
);
}
function formatBriefingStringList(
values: Array<string | undefined>,
limit: number,
@@ -1157,11 +1157,9 @@ describe('AIIntelligence entitlement gating', () => {
await waitFor(() => {
expect(screen.getByText(/Coverage incomplete · 70\/100/)).toBeInTheDocument();
expect(screen.getByText('Next:')).toBeInTheDocument();
expect(screen.getByText('Verify full coverage')).toBeInTheDocument();
expect(screen.getByTestId('patrol-recommended-next-step-action')).toHaveTextContent(
'Run Patrol',
);
expect(screen.queryByText('Next:')).not.toBeInTheDocument();
expect(screen.queryByText('Verify full coverage')).not.toBeInTheDocument();
expect(screen.queryByTestId('patrol-recommended-next-step-action')).not.toBeInTheDocument();
expect(findingsPanelState.latestProps).not.toBeNull();
});
@@ -1330,10 +1328,9 @@ describe('AIIntelligence entitlement gating', () => {
});
expect(screen.getByText(/1 runtime issue · 60\/100/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute(
'href',
'/settings/system-ai',
);
expect(
screen.queryByRole('link', { name: 'Open Patrol provider settings' }),
).not.toBeInTheDocument();
const assessmentShell = screen.getByText('Patrol assessment').closest('section');
expect(assessmentShell).not.toBeNull();
expect(assessmentShell!.className).toContain('border-y');
@@ -113,10 +113,6 @@ describe('aiChatStore', () => {
},
handoffMetadata: {
kind: 'patrol_finding',
recommendedNextStep: 'Open Patrol provider settings',
recommendedNextStepDetail: 'Fix the Patrol runtime issue before continuing.',
recommendedNextStepAction: 'Open Patrol provider settings',
recommendedNextStepActionHref: '/settings/system-ai',
},
});
expect(aiChatStore.isOpen).toBe(true);
@@ -130,8 +126,6 @@ describe('aiChatStore', () => {
]);
expect(aiChatStore.context.handoffMetadata).toMatchObject({
kind: 'patrol_finding',
recommendedNextStepDetail: 'Fix the Patrol runtime issue before continuing.',
recommendedNextStepActionHref: '/settings/system-ai',
});
});
-5
View File
@@ -63,11 +63,6 @@ export interface AIChatHandoffMetadata {
runType?: string;
runStatus?: string;
runtimeFailure?: boolean;
recommendedNextStep?: string;
recommendedNextStepDetail?: string;
recommendedNextStepAction?: string;
recommendedNextStepActionKind?: string;
recommendedNextStepActionHref?: string;
}
export interface AIChatContext {
-5
View File
@@ -411,11 +411,6 @@ export interface AIChatSessionHandoffSummary {
last_known_approval_status?: string;
last_known_action_state?: string;
last_known_action_risk?: string;
recommended_next_step?: string;
recommended_next_step_detail?: string;
recommended_next_step_action?: string;
recommended_next_step_action_kind?: string;
recommended_next_step_action_href?: string;
updated_at?: string;
}
@@ -33,7 +33,7 @@ describe('aiChatPresentation', () => {
);
expect(AI_CHAT_EMPTY_STATE_TITLE).toBe('Ask about your infrastructure');
expect(AI_CHAT_EMPTY_STATE_SUBTITLE).toBe(
'Pulse Assistant uses observed infrastructure context and your configured provider to inspect state, explain findings, and suggest safe next steps.',
'Chat with your configured model using Pulse context and governed tools.',
);
expect(AI_CHAT_NEW_SESSION_SHORT_LABEL).toBe('New');
expect(AI_CHAT_NEW_SESSION_MENU_LABEL).toBe('New session');
@@ -53,17 +53,9 @@ describe('aiChatPresentation', () => {
expect(getAIChatLauncherTitle()).not.toContain('⌘K');
});
it('builds canonical empty-state suggestions for cluster and single-system chat', () => {
expect(getAIChatEmptyStateSuggestions(true)).toEqual([
'Summarize cluster health',
'Find failed services',
'Check node load and pressure',
]);
expect(getAIChatEmptyStateSuggestions(false)).toEqual([
'Summarize system health',
'Check storage pressure',
'Explain recent Patrol findings',
]);
it('keeps the default empty state as plain chat without prompt chips', () => {
expect(getAIChatEmptyStateSuggestions(true)).toEqual([]);
expect(getAIChatEmptyStateSuggestions(false)).toEqual([]);
});
it('uses attached briefing context for scoped assistant handoff empty states', () => {
@@ -3,7 +3,6 @@ import {
getPatrolAssessmentPresentation,
getPatrolAssessmentAction,
getPatrolAssessmentShellPresentation,
getPatrolRecommendedNextStepPresentation,
getPatrolRecencyPresentation,
getPatrolScoreChipLabel,
getPatrolSummaryMetricState,
@@ -392,140 +391,6 @@ describe('getPatrolSummaryPresentation', () => {
).toBeUndefined();
});
it('turns pending approvals into the primary recommended next step', () => {
expect(
getPatrolRecommendedNextStepPresentation({
assessment: {
title: 'Critical issues detected',
description: 'Patrol surfaced one active critical finding.',
eyebrow: 'Patrol assessment',
compactLabel: 'Issues detected',
tone: 'error',
},
verification: {
title: 'Recently verified',
description: 'The most recent full patrol completed successfully.',
compactLabel: 'Recently verified',
tone: 'success',
},
activeFindings: [
{
status: 'active',
severity: 'critical',
resourceId: 'vm-100',
resourceName: 'web-1',
title: 'High CPU usage',
},
] as never,
pendingApprovalCount: 1,
}),
).toEqual({
title: 'Review the pending Patrol approval',
description:
'Patrol is waiting on 1 governed Patrol approval. Review risk, dry-run posture, and expiry before approving or starting another remediation.',
action: {
kind: 'review_approvals',
label: 'Review approvals',
},
tone: 'warning',
});
});
it('recommends a full verification when Patrol has no active findings but coverage is incomplete', () => {
expect(
getPatrolRecommendedNextStepPresentation({
assessment: {
title: 'Coverage incomplete',
description: 'Patrol coverage is incomplete.',
eyebrow: 'Patrol assessment',
compactLabel: 'Coverage incomplete',
tone: 'warning',
},
verification: {
title: 'Recently verified',
description:
'The most recent full patrol completed successfully and checked 47 resources.',
compactLabel: 'Recently verified',
tone: 'success',
},
}),
).toEqual({
title: 'Verify full coverage',
description:
'Run a full Patrol sweep before treating this assessment as an all-clear; recent evidence is incomplete or limited to targeted activity.',
action: {
kind: 'run_patrol',
label: 'Run Patrol',
},
tone: 'warning',
});
});
it('routes Patrol runtime recommendations to provider settings', () => {
expect(
getPatrolRecommendedNextStepPresentation({
assessment: {
title: 'Patrol runtime issue',
description: 'Patrol has an active runtime issue: Provider billing or quota issue.',
eyebrow: 'Patrol assessment',
compactLabel: 'Patrol runtime issue',
tone: 'warning',
},
verification: {
title: 'No recent full patrol',
description: 'Patrol has not completed a recent full verification run yet.',
compactLabel: 'Verification pending',
tone: 'info',
},
activeFindings: [
{
status: 'active',
severity: 'warning',
resourceId: 'ai-service',
resourceName: 'Pulse Patrol Service',
title: 'Pulse Patrol: Provider billing or quota issue',
},
] as never,
}),
).toEqual({
title: 'Restore Patrol visibility',
description:
'Fix the Patrol runtime issue and rerun Patrol before treating the infrastructure assessment as current.',
action: {
kind: 'open_provider_settings',
label: 'Open Patrol provider settings',
href: '/settings/system-ai',
},
tone: 'warning',
});
});
it('keeps a verified healthy Patrol assessment in monitoring mode', () => {
expect(
getPatrolRecommendedNextStepPresentation({
assessment: {
title: 'No active issues detected',
description: 'Infrastructure is healthy with no significant issues detected.',
eyebrow: 'Patrol assessment',
compactLabel: 'No issues found',
tone: 'success',
},
verification: {
title: 'Recently verified',
description: 'The most recent full patrol completed successfully.',
compactLabel: 'Recently verified',
tone: 'success',
},
activeFindings: [],
}),
).toEqual({
title: 'Keep Patrol monitoring',
description:
'Patrol has no active findings and has recent full-run verification. Keep scheduled Patrol enabled and let alert or anomaly triggers start scoped follow-up checks.',
tone: 'success',
});
});
it('splits infrastructure findings from patrol runtime issues in supporting metrics', () => {
expect(
getPatrolSummaryMetricState({
@@ -14,7 +14,7 @@ export const AI_CHAT_DISCOVERY_HINT_BODY =
'Enable it in Settings so Pulse Assistant can reference real services, versions, and commands instead of generic guidance.';
export const AI_CHAT_EMPTY_STATE_TITLE = 'Ask about your infrastructure';
export const AI_CHAT_EMPTY_STATE_SUBTITLE =
'Pulse Assistant uses observed infrastructure context and your configured provider to inspect state, explain findings, and suggest safe next steps.';
'Chat with your configured model using Pulse context and governed tools.';
export const AI_CHAT_INPUT_PLACEHOLDER = 'Ask about your infrastructure...';
export const AI_CHAT_SUGGESTIONS_LABEL = 'Try asking';
export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input';
@@ -35,18 +35,6 @@ export interface AIChatEmptyStatePresentation {
title: string;
}
const AI_CHAT_CLUSTER_EMPTY_STATE_SUGGESTIONS = [
'Summarize cluster health',
'Find failed services',
'Check node load and pressure',
];
const AI_CHAT_SINGLE_SYSTEM_EMPTY_STATE_SUGGESTIONS = [
'Summarize system health',
'Check storage pressure',
'Explain recent Patrol findings',
];
export function getAIChatLauncherTitle(contextName?: unknown) {
if (typeof contextName === 'string' && contextName.trim().length > 0) {
return `Open Pulse Assistant for ${contextName}`;
@@ -56,9 +44,8 @@ export function getAIChatLauncherTitle(contextName?: unknown) {
}
export function getAIChatEmptyStateSuggestions(isCluster: boolean) {
return isCluster
? AI_CHAT_CLUSTER_EMPTY_STATE_SUGGESTIONS
: AI_CHAT_SINGLE_SYSTEM_EMPTY_STATE_SUGGESTIONS;
void isCluster;
return [];
}
export function getAIChatEmptyStatePresentation(args: {
@@ -46,26 +46,6 @@ export interface PatrolAssessmentAction {
href: string;
}
export type PatrolRecommendedNextStepActionKind =
| 'discuss_assessment'
| 'open_provider_settings'
| 'review_approvals'
| 'review_findings'
| 'run_patrol';
export interface PatrolRecommendedNextStepAction {
kind: PatrolRecommendedNextStepActionKind;
label: string;
href?: string;
}
export interface PatrolRecommendedNextStepPresentation {
title: string;
description: string;
action?: PatrolRecommendedNextStepAction;
tone: SemanticTone;
}
export interface PatrolSummaryMetricState {
primaryLabel: string;
primaryValue: number;
@@ -109,9 +89,6 @@ export const PATROL_NO_ISSUES_LABEL = 'No issues found';
const QUIET_ICON_CONTAINER = 'bg-surface border-border';
const QUIET_ICON = 'text-muted';
const QUIET_VALUE = 'text-muted';
const VERIFY_FULL_COVERAGE_DESCRIPTION =
'Run a full Patrol sweep before treating this assessment as an all-clear; recent evidence is incomplete or limited to targeted activity.';
const ACTIVE_PRESENTATION: Record<PatrolSummaryTone, PatrolSummaryPresentation> = {
critical: {
iconClass: 'text-red-500 dark:text-red-400',
@@ -295,131 +272,6 @@ export function getPatrolAssessmentAction(args: {
return undefined;
}
function formatPendingApprovalCount(count: number): string {
return `${count} governed Patrol approval${count === 1 ? '' : 's'}`;
}
function assessmentHasCoverageGap(assessment: PatrolAssessmentPresentation): boolean {
const title = assessment.title.trim().toLowerCase();
const description = assessment.description.trim().toLowerCase();
const compactLabel = assessment.compactLabel.trim().toLowerCase();
return (
title.includes('coverage incomplete') ||
description.includes('coverage incomplete') ||
compactLabel.includes('coverage incomplete')
);
}
export function getPatrolRecommendedNextStepPresentation(args: {
assessment: PatrolAssessmentPresentation;
verification: PatrolVerificationPresentation;
activeFindings?: PatrolAssessmentFinding[];
pendingApprovalCount?: number;
}): PatrolRecommendedNextStepPresentation {
const classified = classifyActiveFindings(args.activeFindings);
const pendingApprovalCount = Math.max(0, args.pendingApprovalCount ?? 0);
if (pendingApprovalCount > 0) {
return {
title:
pendingApprovalCount === 1
? 'Review the pending Patrol approval'
: 'Review pending Patrol approvals',
description: `Patrol is waiting on ${formatPendingApprovalCount(pendingApprovalCount)}. Review risk, dry-run posture, and expiry before approving or starting another remediation.`,
action: {
kind: 'review_approvals',
label: 'Review approvals',
},
tone: 'warning',
};
}
if (classified.infrastructureTotal === 0 && classified.runtimeTotal > 0) {
const action = getPatrolProviderSettingsAction();
return {
title: 'Restore Patrol visibility',
description:
'Fix the Patrol runtime issue and rerun Patrol before treating the infrastructure assessment as current.',
action: {
kind: 'open_provider_settings',
label: action.label,
href: action.href,
},
tone: args.assessment.tone,
};
}
if (classified.infrastructureCritical > 0) {
return {
title: 'Triage the critical finding',
description:
'Start with the highest-risk active finding, review the attached evidence and approval posture, then choose the safest governed action.',
action: {
kind: 'review_findings',
label: 'Review findings',
},
tone: 'error',
};
}
if (classified.infrastructureTotal > 0) {
return {
title: 'Review active findings',
description:
'Use the findings workspace to prioritize current risk, recent changes, and any governed remediation before waiting for the next scheduled Patrol run.',
action: {
kind: 'review_findings',
label: 'Review findings',
},
tone: 'warning',
};
}
if (assessmentHasCoverageGap(args.assessment)) {
return {
title: 'Verify full coverage',
description: VERIFY_FULL_COVERAGE_DESCRIPTION,
action: {
kind: 'run_patrol',
label: 'Run Patrol',
},
tone: args.assessment.tone,
};
}
if (args.verification.tone !== 'success') {
return {
title: 'Verify full coverage',
description: VERIFY_FULL_COVERAGE_DESCRIPTION,
action: {
kind: 'run_patrol',
label: 'Run Patrol',
},
tone: args.verification.tone,
};
}
if (args.assessment.tone !== 'success') {
return {
title: 'Review assessment evidence',
description:
'Review the supporting context or discuss the assessment with Assistant to understand why Patrol still marks it as needing attention.',
action: {
kind: 'discuss_assessment',
label: 'Discuss with Assistant',
},
tone: args.assessment.tone,
};
}
return {
title: 'Keep Patrol monitoring',
description:
'Patrol has no active findings and has recent full-run verification. Keep scheduled Patrol enabled and let alert or anomaly triggers start scoped follow-up checks.',
tone: 'success',
};
}
function joinAssessmentParts(parts: string[]): string {
if (parts.length <= 1) return parts[0] ?? '';
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
@@ -936,7 +936,7 @@ func TestService_ExecuteStream_OperatorBriefingHandoffHonorsResourcePolicy(t *te
"Briefing Source: Pulse Patrol structured finding",
"Resource: finance-vm (vm) [vm-100] on pve-secret",
"Current Conclusion: finance-vm on pve-secret saturated CPU during backup.",
"Recommended Next Step: Review finance-payroll after backup completion.",
"Attached Context: finance-payroll backup completion is relevant.",
"Operator Boundary: Treat Patrol data as product context for explanation and review.",
}, "\n"),
HandoffResources: []HandoffResource{{
@@ -972,7 +972,7 @@ func TestService_ExecuteStream_OperatorBriefingHandoffHonorsResourcePolicy(t *te
"[Operator Briefing]",
"Resource: redacted by policy (vm) [redacted by policy] on redacted by policy",
"Current Conclusion: redacted by policy on redacted by policy saturated CPU during backup.",
"Recommended Next Step: Review redacted by policy after backup completion.",
"Attached Context: redacted by policy backup completion is relevant.",
"[Resource Policy Context]",
"Resource Policy: virtual machine resource; status warning; local-only context",
"Policy Boundary: Resource policy is read-only data-handling context",
+9 -251
View File
@@ -177,18 +177,6 @@ func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
if normalized.Kind != sessionHandoffKindPatrolRun && normalized.Kind != sessionHandoffKindPatrolConfigurationFailure {
normalized.RuntimeFailure = false
}
if normalized.Kind == sessionHandoffKindPatrolAssessment || normalized.Kind == sessionHandoffKindPatrolFinding {
normalized.RecommendedNextStep = safeSessionHandoffSummaryText(metadata.RecommendedNextStep, 160)
normalized.RecommendedNextStepDetail = safeSessionHandoffSummaryText(metadata.RecommendedNextStepDetail, 240)
normalized.RecommendedNextStepAction = safeSessionHandoffSummaryText(metadata.RecommendedNextStepAction, 120)
normalized.RecommendedNextStepActionKind = safePatrolRecommendationActionKind(metadata.RecommendedNextStepActionKind)
if normalized.RecommendedNextStepAction != "" {
normalized.RecommendedNextStepActionHref = safePatrolHandoffRoute(metadata.RecommendedNextStepActionHref)
}
if normalized.RecommendedNextStep == "" && normalized.RecommendedNextStepAction != "" {
normalized.RecommendedNextStep = normalized.RecommendedNextStepAction
}
}
return normalized
}
@@ -244,193 +232,6 @@ func inferPatrolRunHandoffMetadata(handoffContext string) HandoffMetadata {
return NormalizeHandoffMetadata(metadata)
}
func patrolAssessmentRecommendedNextStepSummary(kind string, handoffContext string) (string, string, string, string) {
if kind != sessionHandoffKindPatrolAssessment {
return "", "", "", ""
}
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
if len(lines) == 0 {
return "", "", "", ""
}
sawAssessmentContext := false
sawPatrolSource := false
var recommendedNextStep string
var recommendedNextStepDetail string
var recommendedNextStepAction string
var recommendedNextStepActionKind string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "[Patrol Assessment Context]" {
sawAssessmentContext = true
continue
}
if !sawAssessmentContext {
continue
}
label, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
label = strings.ToLower(strings.TrimSpace(label))
value = strings.TrimSpace(value)
switch label {
case "source":
if strings.EqualFold(value, "Pulse Patrol current assessment") {
sawPatrolSource = true
}
case "recommended next step":
recommendedNextStep = safeSessionHandoffSummaryText(value, 160)
case "recommended next step detail":
recommendedNextStepDetail = safeSessionHandoffSummaryText(value, 240)
case "recommended next step action":
recommendedNextStepAction, recommendedNextStepActionKind = safePatrolAssessmentRecommendationAction(value)
}
}
if !sawAssessmentContext || !sawPatrolSource {
return "", "", "", ""
}
if recommendedNextStep == "" && recommendedNextStepAction != "" {
recommendedNextStep = recommendedNextStepAction
}
return recommendedNextStep, recommendedNextStepDetail, recommendedNextStepAction, recommendedNextStepActionKind
}
func safePatrolAssessmentRecommendationAction(value string) (string, string) {
action := safeSessionHandoffSummaryText(value, 120)
if action == "" {
return "", ""
}
for _, candidate := range []struct {
suffix string
kind string
}{
{" (discuss_assessment)", "discuss_assessment"},
{" (open_provider_settings)", "open_provider_settings"},
{" (review_approvals)", "review_approvals"},
{" (review_findings)", "review_findings"},
{" (run_patrol)", "run_patrol"},
} {
if strings.HasSuffix(action, candidate.suffix) {
return strings.TrimSpace(strings.TrimSuffix(action, candidate.suffix)), candidate.kind
}
}
return action, ""
}
func safePatrolRecommendationActionKind(value string) string {
normalized := strings.ToLower(trimHandoffMetadataField(value, 64))
switch normalized {
case "discuss_assessment",
"open_provider_settings",
"review_approvals",
"review_findings",
"run_patrol":
return normalized
default:
return ""
}
}
func patrolFindingNextStepSummary(kind string, handoffContext string) (string, string) {
if kind != sessionHandoffKindPatrolFinding {
return "", ""
}
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
if len(lines) == 0 {
return "", ""
}
sawFindingContext := false
sawPatrolSource := false
var nextStepAction string
var nextStepActionHref string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "[Patrol Finding Context]" {
sawFindingContext = true
continue
}
if !sawFindingContext {
continue
}
label, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
label = strings.ToLower(strings.TrimSpace(label))
value = strings.TrimSpace(value)
switch label {
case "source":
if strings.EqualFold(value, "Pulse Patrol finding handoff") {
sawPatrolSource = true
}
case "patrol next step":
nextStepAction = safeSessionHandoffSummaryText(value, 120)
case "patrol next step route":
nextStepActionHref = safePatrolHandoffRoute(value)
}
}
if !sawFindingContext || !sawPatrolSource || nextStepAction == "" {
return "", ""
}
return nextStepAction, nextStepActionHref
}
func safePatrolHandoffRoute(value string) string {
route := trimHandoffMetadataField(value, 160)
switch route {
case "/patrol", "/settings/system-ai":
return route
default:
return ""
}
}
func safeSessionHandoffSummaryText(value string, maxRunes int) string {
normalized := trimHandoffMetadataField(value, maxRunes)
if normalized == "" || sessionHandoffSummaryTextShouldBeWithheld(normalized) {
return ""
}
return normalized
}
func sessionHandoffSummaryTextShouldBeWithheld(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{
"password",
"secret",
"token",
"api key",
"api_key",
"api-key",
"credential",
"private key",
"private_key",
"private-key",
"systemctl",
"sudo ",
"bash ",
"sh -c",
"curl ",
"wget ",
"kubectl ",
"docker ",
"ssh ",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func modelContextEmpty(modelContext *sessionModelContext) bool {
if modelContext == nil {
return true
@@ -496,60 +297,17 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
} else if findingID != "" {
kind = sessionHandoffKindPatrolFinding
}
recommendedNextStep := metadata.RecommendedNextStep
recommendedNextStepDetail := metadata.RecommendedNextStepDetail
recommendedNextStepAction := metadata.RecommendedNextStepAction
recommendedNextStepActionKind := metadata.RecommendedNextStepActionKind
recommendedNextStepActionHref := metadata.RecommendedNextStepActionHref
if recommendedNextStep == "" || recommendedNextStepDetail == "" || recommendedNextStepAction == "" || recommendedNextStepActionKind == "" {
contextNextStep, contextNextStepDetail, contextNextStepAction, contextNextStepActionKind := patrolAssessmentRecommendedNextStepSummary(
kind,
modelContext.HandoffContext,
)
if recommendedNextStep == "" {
recommendedNextStep = contextNextStep
}
if recommendedNextStepDetail == "" {
recommendedNextStepDetail = contextNextStepDetail
}
if recommendedNextStepAction == "" {
recommendedNextStepAction = contextNextStepAction
}
if recommendedNextStepActionKind == "" {
recommendedNextStepActionKind = contextNextStepActionKind
}
}
if recommendedNextStepAction == "" || recommendedNextStepActionHref == "" {
contextNextStepAction, contextNextStepActionHref := patrolFindingNextStepSummary(
kind,
modelContext.HandoffContext,
)
if recommendedNextStepAction == "" {
recommendedNextStepAction = contextNextStepAction
}
if recommendedNextStepActionHref == "" {
recommendedNextStepActionHref = contextNextStepActionHref
}
}
if recommendedNextStep == "" && recommendedNextStepAction != "" {
recommendedNextStep = recommendedNextStepAction
}
summary := &SessionHandoffSummary{
Kind: kind,
FindingID: findingID,
RunID: metadata.RunID,
RunType: metadata.RunType,
RunStatus: metadata.RunStatus,
RuntimeFailure: metadata.RuntimeFailure,
HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "",
ResourceCount: len(resources),
ActionCount: len(actions),
RecommendedNextStep: recommendedNextStep,
RecommendedNextStepDetail: recommendedNextStepDetail,
RecommendedNextStepAction: recommendedNextStepAction,
RecommendedNextStepActionKind: recommendedNextStepActionKind,
RecommendedNextStepActionHref: recommendedNextStepActionHref,
Kind: kind,
FindingID: findingID,
RunID: metadata.RunID,
RunType: metadata.RunType,
RunStatus: metadata.RunStatus,
RuntimeFailure: metadata.RuntimeFailure,
HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "",
ResourceCount: len(resources),
ActionCount: len(actions),
}
if kind != sessionHandoffKindPatrolRun {
summary.RunID = ""
+13 -143
View File
@@ -529,9 +529,6 @@ func TestSessionStore_ListKeepsPatrolAssessmentHandoffIdentity(t *testing.T) {
"[Patrol Assessment Context]",
"Source: Pulse Patrol current assessment",
"Assessment: Coverage incomplete",
"Recommended Next Step: Verify full coverage",
"Recommended Next Step Detail: Run a full Patrol sweep before treating the assessment as current.",
"Recommended Next Step Action: Run Patrol (run_patrol)",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "", handoffContext, nil, []HandoffAction{{
FindingID: "finding-123",
@@ -568,12 +565,6 @@ func TestSessionStore_ListKeepsPatrolAssessmentHandoffIdentity(t *testing.T) {
if summary.LastKnownApprovalStatus != "pending" || summary.LastKnownActionRisk != "high" {
t.Fatalf("approval posture = %#v, want safe action summary", summary)
}
if summary.RecommendedNextStep != "Verify full coverage" ||
summary.RecommendedNextStepDetail != "Run a full Patrol sweep before treating the assessment as current." ||
summary.RecommendedNextStepAction != "Run Patrol" ||
summary.RecommendedNextStepActionKind != "run_patrol" {
t.Fatalf("recommended next step summary = %#v, want safe Patrol recommendation", summary)
}
payload, err := json.Marshal(sessions)
if err != nil {
t.Fatalf("Marshal sessions failed: %v", err)
@@ -590,135 +581,7 @@ func TestSessionStore_ListKeepsPatrolAssessmentHandoffIdentity(t *testing.T) {
}
}
func TestSessionStore_ListKeepsSafePatrolFindingNextStepRoute(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
handoffContext := strings.Join([]string{
"[Patrol Finding Context]",
"Source: Pulse Patrol finding handoff",
"Finding ID: finding-provider-settings",
"Patrol Next Step: Open Patrol provider settings",
"Patrol Next Step Route: /settings/system-ai",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{}); err != nil {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
}
summary := sessions[0].HandoffSummary
if summary.Kind != sessionHandoffKindPatrolFinding {
t.Fatalf("handoff kind = %q, want %q", summary.Kind, sessionHandoffKindPatrolFinding)
}
if summary.FindingID != "finding-provider-settings" {
t.Fatalf("finding ID = %q, want finding-provider-settings", summary.FindingID)
}
if summary.RecommendedNextStep != "Open Patrol provider settings" ||
summary.RecommendedNextStepAction != "Open Patrol provider settings" ||
summary.RecommendedNextStepActionHref != "/settings/system-ai" {
t.Fatalf("next step summary = %#v, want safe route-owned Patrol next step", summary)
}
}
func TestSessionStore_ListPrefersStructuredPatrolFindingNextStepMetadata(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
handoffContext := strings.Join([]string{
"[Patrol Finding Context]",
"Source: Pulse Patrol finding handoff",
"Finding ID: finding-provider-settings",
"Patrol Next Step: sudo restart",
"Patrol Next Step Route: javascript:alert(1)",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{
Kind: "patrol_finding",
RecommendedNextStep: "Open Patrol provider settings",
RecommendedNextStepDetail: "Fix the Patrol runtime issue before continuing.",
RecommendedNextStepAction: "Open Patrol provider settings",
RecommendedNextStepActionHref: "/settings/system-ai",
}); err != nil {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
}
summary := sessions[0].HandoffSummary
if summary.RecommendedNextStep != "Open Patrol provider settings" ||
summary.RecommendedNextStepDetail != "Fix the Patrol runtime issue before continuing." ||
summary.RecommendedNextStepAction != "Open Patrol provider settings" ||
summary.RecommendedNextStepActionHref != "/settings/system-ai" {
t.Fatalf("next step summary = %#v, want structured safe Patrol next step", summary)
}
}
func TestSessionStore_ListWithholdsUnsafePatrolFindingNextStepRoute(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
handoffContext := strings.Join([]string{
"[Patrol Finding Context]",
"Source: Pulse Patrol finding handoff",
"Finding ID: finding-provider-settings",
"Patrol Next Step: Open Patrol provider settings",
"Patrol Next Step Route: javascript:alert(1)",
}, "\n")
if err := store.SetModelHandoffEnvelope(session.ID, "finding-provider-settings", handoffContext, nil, nil, HandoffMetadata{}); err != nil {
t.Fatalf("SetModelHandoffEnvelope failed: %v", err)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(sessions) != 1 || sessions[0].HandoffSummary == nil {
t.Fatalf("sessions = %#v, want one session with handoff summary", sessions)
}
summary := sessions[0].HandoffSummary
if summary.RecommendedNextStepAction != "Open Patrol provider settings" {
t.Fatalf("next step action = %q, want safe label preserved", summary.RecommendedNextStepAction)
}
if summary.RecommendedNextStepActionHref != "" {
t.Fatalf("next step href = %q, want unsafe route withheld", summary.RecommendedNextStepActionHref)
}
}
func TestSessionStore_ListWithholdsUnsafePatrolAssessmentRecommendationSummary(t *testing.T) {
func TestSessionStore_ListIgnoresLegacyPatrolRecommendationText(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
@@ -751,11 +614,18 @@ func TestSessionStore_ListWithholdsUnsafePatrolAssessmentRecommendationSummary(t
}
summary := sessions[0].HandoffSummary
if summary.RecommendedNextStep != "" ||
summary.RecommendedNextStepDetail != "" ||
summary.RecommendedNextStepAction != "" ||
summary.RecommendedNextStepActionKind != "" {
t.Fatalf("unsafe recommendation summary = %#v, want withheld recommendation fields", summary)
if summary.Kind != sessionHandoffKindPatrolAssessment || !summary.HasModelContext {
t.Fatalf("summary = %#v, want assessment handoff identity without recommendation fields", summary)
}
payload, err := json.Marshal(sessions)
if err != nil {
t.Fatalf("Marshal sessions failed: %v", err)
}
publicJSON := string(payload)
for _, forbidden := range []string{"recommended_next_step", "systemctl", "abc123", "curl"} {
if strings.Contains(publicJSON, forbidden) {
t.Fatalf("public session JSON leaked %q: %s", forbidden, publicJSON)
}
}
}
+20 -30
View File
@@ -25,26 +25,21 @@ type Session struct {
// session-scoped model handoff metadata. It intentionally omits model-only
// context text and raw remediation details.
type SessionHandoffSummary struct {
Kind string `json:"kind,omitempty"`
FindingID string `json:"finding_id,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
HasModelContext bool `json:"has_model_context"`
ResourceCount int `json:"resource_count,omitempty"`
PrimaryResource *HandoffResource `json:"primary_resource,omitempty"`
ActionCount int `json:"action_count,omitempty"`
RequiresApproval bool `json:"requires_approval,omitempty"`
LastKnownApprovalStatus string `json:"last_known_approval_status,omitempty"`
LastKnownActionState string `json:"last_known_action_state,omitempty"`
LastKnownActionRisk string `json:"last_known_action_risk,omitempty"`
RecommendedNextStep string `json:"recommended_next_step,omitempty"`
RecommendedNextStepDetail string `json:"recommended_next_step_detail,omitempty"`
RecommendedNextStepAction string `json:"recommended_next_step_action,omitempty"`
RecommendedNextStepActionKind string `json:"recommended_next_step_action_kind,omitempty"`
RecommendedNextStepActionHref string `json:"recommended_next_step_action_href,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Kind string `json:"kind,omitempty"`
FindingID string `json:"finding_id,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
HasModelContext bool `json:"has_model_context"`
ResourceCount int `json:"resource_count,omitempty"`
PrimaryResource *HandoffResource `json:"primary_resource,omitempty"`
ActionCount int `json:"action_count,omitempty"`
RequiresApproval bool `json:"requires_approval,omitempty"`
LastKnownApprovalStatus string `json:"last_known_approval_status,omitempty"`
LastKnownActionState string `json:"last_known_action_state,omitempty"`
LastKnownActionRisk string `json:"last_known_action_risk,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// Message represents a chat message
@@ -166,16 +161,11 @@ type HandoffAction struct {
// model-only handoff. It must not include model context text, command payloads,
// remediation details, or provider/runtime error detail.
type HandoffMetadata struct {
Kind string `json:"kind,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
RecommendedNextStep string `json:"recommended_next_step,omitempty"`
RecommendedNextStepDetail string `json:"recommended_next_step_detail,omitempty"`
RecommendedNextStepAction string `json:"recommended_next_step_action,omitempty"`
RecommendedNextStepActionKind string `json:"recommended_next_step_action_kind,omitempty"`
RecommendedNextStepActionHref string `json:"recommended_next_step_action_href,omitempty"`
Kind string `json:"kind,omitempty"`
RunID string `json:"run_id,omitempty"`
RunType string `json:"run_type,omitempty"`
RunStatus string `json:"run_status,omitempty"`
RuntimeFailure bool `json:"runtime_failure,omitempty"`
}
// ExecuteRequest represents a chat execution request
@@ -1,293 +0,0 @@
// capacity_action_templates.go: deterministic remediation proposals for
// capacity findings.
//
// When a capacity finding crosses (or is projected to cross) a threshold,
// patrol_findings.go projects the finding into a CapacityFindingInput and
// asks this registry for a deterministic ActionPlan proposal. The proposal
// is attached to the finding's RemediationPlan and surfaced in
// FindingsPanel.tsx as an explicit approve/reject card.
//
// Every template here MUST set RequiresApproval=true. When no Pulse write
// capability is wired for the proposed remediation, the template sets
// Allowed=false and ships preflight-only intent so the operator can
// approve and act manually until a capability is added. We intentionally
// do NOT invent capabilities in pulse_control / agentexec; making a
// proposal that references an unimplemented capability would be a lie
// at the audit layer.
package forecast
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// CapacityActionPlanSource identifies forecast-driven proposals on the wire.
// FindingsPanel uses this to render the capacity-forecast approval card
// variant rather than the generic remediation plan card.
const CapacityActionPlanSource = "capacity_forecast"
// proposalTTL controls how long a proposed plan remains presentable before
// it is considered stale. Operator approval still flows through the action
// engine (which has its own freshness check), so this is just the
// presentation-side staleness for findings that go un-actioned.
const proposalTTL = 30 * time.Minute
// CapacityFindingInput is the minimum context the registry needs to build a
// deterministic capacity-remediation proposal.
//
// patrol_findings.go projects a *Finding plus any available forecast
// snapshot into this struct so the forecast package can stay free of an
// import on the broader internal/ai package.
type CapacityFindingInput struct {
FindingID string
ResourceID string
ResourceName string
ResourceType string
Node string
Metric string
CurrentValue float64
PredictedValue float64
ThresholdValue float64
TimeToThreshold *time.Duration
Now time.Time
}
type templateKey struct {
ResourceType string
Metric string
}
type capacityActionTemplate func(input CapacityFindingInput) *unifiedresources.ActionPlan
// capacityActionTemplates registers the deterministic templates.
//
// Lookups are normalized via lower-case + trim on both fields. The wire-in
// in patrol_findings.go funnels VM/CT and PBS findings through the
// canonical aliases below so callers don't have to remember the
// resource_type variants used across signal detectors and unified
// resources.
var capacityActionTemplates = map[templateKey]capacityActionTemplate{
{ResourceType: "pbs-datastore", Metric: "usage_percent"}: pbsDatastorePruneAndGCTemplate,
{ResourceType: "pbs", Metric: "usage_percent"}: pbsDatastorePruneAndGCTemplate,
{ResourceType: "storage", Metric: "usage_percent"}: zfsPoolSnapshotPruneTemplate,
{ResourceType: "qemu", Metric: "disk_usage_percent"}: vmDiskExpandTemplate,
{ResourceType: "vm", Metric: "disk_usage_percent"}: vmDiskExpandTemplate,
{ResourceType: "lxc", Metric: "disk_usage_percent"}: vmDiskExpandTemplate,
{ResourceType: "system-container", Metric: "disk_usage_percent"}: vmDiskExpandTemplate,
}
// BuildActionPlanForFinding returns a deterministic ActionPlan proposal for
// the given (resourceType, metric) pair, or nil if no template is
// registered.
//
// The returned plan is guaranteed to have RequiresApproval=true. Templates
// can choose Allowed=false to indicate "no write capability wired yet —
// preflight-only proposal."
func BuildActionPlanForFinding(input CapacityFindingInput) *unifiedresources.ActionPlan {
key := templateKey{
ResourceType: strings.ToLower(strings.TrimSpace(input.ResourceType)),
Metric: strings.ToLower(strings.TrimSpace(input.Metric)),
}
fn, ok := capacityActionTemplates[key]
if !ok {
return nil
}
plan := fn(input)
if plan == nil {
return nil
}
plan.RequiresApproval = true
return plan
}
// HasCapacityActionTemplate reports whether a template exists for the
// (resourceType, metric) pair without constructing a plan.
func HasCapacityActionTemplate(resourceType, metric string) bool {
_, ok := capacityActionTemplates[templateKey{
ResourceType: strings.ToLower(strings.TrimSpace(resourceType)),
Metric: strings.ToLower(strings.TrimSpace(metric)),
}]
return ok
}
// CapacityActionTemplateKey is a registered (resourceType, metric) pair.
type CapacityActionTemplateKey struct {
ResourceType string
Metric string
}
// CapacityActionTemplateKeys returns the registered (resourceType, metric)
// pairs. Useful for tests and for surfacing the catalog in observability
// tooling. Order is not stable across calls.
func CapacityActionTemplateKeys() []CapacityActionTemplateKey {
out := make([]CapacityActionTemplateKey, 0, len(capacityActionTemplates))
for k := range capacityActionTemplates {
out = append(out, CapacityActionTemplateKey{ResourceType: k.ResourceType, Metric: k.Metric})
}
return out
}
func resolveNow(in CapacityFindingInput) time.Time {
if !in.Now.IsZero() {
return in.Now.UTC()
}
return time.Now().UTC()
}
func displayName(in CapacityFindingInput) string {
name := strings.TrimSpace(in.ResourceName)
if name == "" {
name = strings.TrimSpace(in.ResourceID)
}
if name == "" {
name = "(unknown)"
}
return name
}
func currentStateString(in CapacityFindingInput) string {
parts := []string{fmt.Sprintf("%s=%.1f%%", in.Metric, in.CurrentValue)}
if in.PredictedValue > 0 && in.PredictedValue != in.CurrentValue {
parts = append(parts, fmt.Sprintf("projected=%.1f%%", in.PredictedValue))
}
if in.ThresholdValue > 0 {
parts = append(parts, fmt.Sprintf("threshold=%.1f%%", in.ThresholdValue))
}
if in.TimeToThreshold != nil {
parts = append(parts, fmt.Sprintf("ttb=%s", roundDuration(*in.TimeToThreshold)))
}
return strings.Join(parts, ", ")
}
func roundDuration(d time.Duration) time.Duration {
if d >= 24*time.Hour {
return d.Round(time.Hour)
}
if d >= time.Hour {
return d.Round(time.Minute)
}
return d.Round(time.Second)
}
// --- Templates ---
func pbsDatastorePruneAndGCTemplate(in CapacityFindingInput) *unifiedresources.ActionPlan {
now := resolveNow(in)
name := displayName(in)
msg := fmt.Sprintf(
"PBS datastore %q is at %.1f%% usage (projected %.1f%%). Propose: prune snapshots against the configured retention policy, then run garbage-collect to reclaim chunk-store space. No PBS prune/GC capability is wired into Pulse yet, so this proposal is preflight-only — approve to record intent and run the remediation manually until the capability lands.",
name, in.CurrentValue, in.PredictedValue,
)
return &unifiedresources.ActionPlan{
ActionID: "capacity-forecast-" + uuid.NewString(),
Allowed: false,
RequiresApproval: true,
ApprovalPolicy: unifiedresources.ApprovalAdmin,
Message: msg,
PlannedAt: now,
ExpiresAt: now.Add(proposalTTL),
Preflight: &unifiedresources.ActionPreflight{
Target: fmt.Sprintf("pbs-datastore/%s", name),
CurrentState: currentStateString(in),
IntendedChange: "Prune backups against retention policy, then run garbage-collect.",
DryRunAvailable: false,
DryRunSummary: "No PBS prune/GC capability is wired into Pulse yet; this proposal records intent and surfaces the operator-facing remediation.",
SafetyChecks: []string{
"Operator must explicitly approve before any execution path is wired.",
"This proposal ships with Allowed=false; the action broker will refuse execution.",
"Verify the configured retention policy matches your recovery objectives before approving.",
},
VerificationSteps: []string{
"After manual prune+GC, re-check the datastore usage on the next Patrol pass.",
"Confirm chunk-store free space increased and that recent backups remain restorable.",
},
GeneratedAt: now,
},
}
}
func zfsPoolSnapshotPruneTemplate(in CapacityFindingInput) *unifiedresources.ActionPlan {
now := resolveNow(in)
name := displayName(in)
msg := fmt.Sprintf(
"Storage pool %q is at %.1f%% usage (projected %.1f%%). Propose: prune oldest auto-snapshots and surface the largest reclaimable datasets. No snapshot-prune capability is wired into Pulse yet, so this proposal is preflight-only — approve to record intent and run the remediation manually until the capability lands.",
name, in.CurrentValue, in.PredictedValue,
)
return &unifiedresources.ActionPlan{
ActionID: "capacity-forecast-" + uuid.NewString(),
Allowed: false,
RequiresApproval: true,
ApprovalPolicy: unifiedresources.ApprovalAdmin,
Message: msg,
PlannedAt: now,
ExpiresAt: now.Add(proposalTTL),
Preflight: &unifiedresources.ActionPreflight{
Target: fmt.Sprintf("storage/%s", name),
CurrentState: currentStateString(in),
IntendedChange: "Prune oldest auto-snapshots, then list largest reclaimable datasets for review.",
DryRunAvailable: false,
DryRunSummary: "No snapshot-prune capability is wired into Pulse yet; this proposal records intent and surfaces the operator-facing remediation.",
SafetyChecks: []string{
"Operator must explicitly approve before any execution path is wired.",
"This proposal ships with Allowed=false; the action broker will refuse execution.",
"Confirm snapshot retention is sufficient before approving — this proposal targets oldest auto-snapshots only.",
},
VerificationSteps: []string{
"After manual snapshot prune, re-check pool usage on the next Patrol pass.",
"Confirm that critical snapshots required for rollback or replication are still present.",
},
GeneratedAt: now,
},
}
}
func vmDiskExpandTemplate(in CapacityFindingInput) *unifiedresources.ActionPlan {
now := resolveNow(in)
name := displayName(in)
rt := strings.ToUpper(strings.TrimSpace(in.ResourceType))
if rt == "" {
rt = "GUEST"
}
msg := fmt.Sprintf(
"%s %q is at %.1f%% disk usage (projected %.1f%%). Propose: expand the guest disk by the next sensible increment, or compact the qcow2 image if the underlying allocation has grown beyond the in-guest footprint. No guest-disk write capability is wired into Pulse yet, so this proposal is preflight-only — approve to record intent and run the remediation manually until the capability lands.",
rt, name, in.CurrentValue, in.PredictedValue,
)
return &unifiedresources.ActionPlan{
ActionID: "capacity-forecast-" + uuid.NewString(),
Allowed: false,
RequiresApproval: true,
ApprovalPolicy: unifiedresources.ApprovalAdmin,
Message: msg,
PlannedAt: now,
ExpiresAt: now.Add(proposalTTL),
Preflight: &unifiedresources.ActionPreflight{
Target: fmt.Sprintf("%s/%s", strings.ToLower(strings.TrimSpace(in.ResourceType)), name),
CurrentState: currentStateString(in),
IntendedChange: "Expand guest disk by the next sensible increment, or compact the qcow2 image.",
DryRunAvailable: false,
DryRunSummary: "No guest-disk capability is wired into Pulse yet; this proposal records intent and surfaces the operator-facing remediation.",
SafetyChecks: []string{
"Operator must explicitly approve before any execution path is wired.",
"This proposal ships with Allowed=false; the action broker will refuse execution.",
"Take a snapshot before expanding or compacting — disk operations risk filesystem damage if interrupted.",
},
VerificationSteps: []string{
"After manual expand/compact, re-check disk usage on the next Patrol pass.",
"Inside the guest, confirm the partition and filesystem were resized to use the new capacity.",
},
GeneratedAt: now,
},
}
}
@@ -1,217 +0,0 @@
package forecast
import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestBuildActionPlanForFinding_ReturnsNilForUnknownPair(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-1",
ResourceType: "wireguard-tunnel",
Metric: "handshakes_per_minute",
})
if plan != nil {
t.Fatalf("expected nil plan for unknown (resourceType, metric); got %+v", plan)
}
}
func TestBuildActionPlanForFinding_RequiresApprovalInvariantHoldsForEveryTemplate(t *testing.T) {
for _, key := range CapacityActionTemplateKeys() {
t.Run(key.ResourceType+"/"+key.Metric, func(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-" + key.ResourceType,
ResourceID: "res/" + key.ResourceType,
ResourceName: "test-" + key.ResourceType,
ResourceType: key.ResourceType,
Metric: key.Metric,
CurrentValue: 88.0,
PredictedValue: 95.0,
ThresholdValue: 90.0,
})
if plan == nil {
t.Fatal("expected plan, got nil")
}
if !plan.RequiresApproval {
t.Errorf("RequiresApproval = false; templates MUST set RequiresApproval=true")
}
if plan.Allowed {
t.Errorf("Allowed = true; lane currently ships preflight-only proposals (no write capability wired). " +
"If a capability lands later, update this assertion alongside the template.")
}
if plan.ApprovalPolicy != unifiedresources.ApprovalAdmin {
t.Errorf("ApprovalPolicy = %q; want %q", plan.ApprovalPolicy, unifiedresources.ApprovalAdmin)
}
if plan.Message == "" {
t.Error("Message must be non-empty operator-facing description")
}
if plan.ActionID == "" {
t.Error("ActionID must be set")
}
if !strings.HasPrefix(plan.ActionID, "capacity-forecast-") {
t.Errorf("ActionID = %q; want capacity-forecast- prefix so audit can identify forecast-driven actions", plan.ActionID)
}
if plan.Preflight == nil {
t.Fatal("Preflight is required so operators can see the proposed change before approving")
}
if plan.Preflight.IntendedChange == "" {
t.Error("Preflight.IntendedChange must describe the proposed remediation")
}
if plan.Preflight.DryRunAvailable {
t.Error("Preflight.DryRunAvailable = true; no provider dry-run exists yet for these templates")
}
if len(plan.Preflight.SafetyChecks) == 0 {
t.Error("Preflight.SafetyChecks must be non-empty so the audit trail records the gate")
}
if len(plan.Preflight.VerificationSteps) == 0 {
t.Error("Preflight.VerificationSteps must be non-empty so post-action verification is documented")
}
if plan.PlannedAt.IsZero() {
t.Error("PlannedAt must be set")
}
if !plan.ExpiresAt.After(plan.PlannedAt) {
t.Errorf("ExpiresAt (%v) must be after PlannedAt (%v)", plan.ExpiresAt, plan.PlannedAt)
}
})
}
}
func TestBuildActionPlanForFinding_PBSDatastoreTemplateMessageMentionsPruneAndGC(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-pbs-1",
ResourceID: "pbs-1/datastore/main",
ResourceName: "main",
ResourceType: "pbs-datastore",
Metric: "usage_percent",
CurrentValue: 91.4,
PredictedValue: 96.0,
ThresholdValue: 90.0,
})
if plan == nil {
t.Fatal("expected plan, got nil")
}
msg := strings.ToLower(plan.Message)
if !strings.Contains(msg, "prune") {
t.Errorf("PBS template message missing prune verb: %q", plan.Message)
}
if !strings.Contains(msg, "garbage-collect") && !strings.Contains(msg, "garbage collect") && !strings.Contains(msg, "gc") {
t.Errorf("PBS template message missing garbage-collect verb: %q", plan.Message)
}
if !strings.Contains(plan.Message, "91.4") {
t.Errorf("PBS template message missing current value 91.4: %q", plan.Message)
}
if !strings.Contains(plan.Message, "96.0") {
t.Errorf("PBS template message missing projected value 96.0: %q", plan.Message)
}
}
func TestBuildActionPlanForFinding_StoragePoolTemplateMessageMentionsSnapshotPrune(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-zfs-1",
ResourceID: "node-a/storage/tank",
ResourceName: "tank",
ResourceType: "storage",
Metric: "usage_percent",
CurrentValue: 87.2,
PredictedValue: 93.5,
ThresholdValue: 90.0,
})
if plan == nil {
t.Fatal("expected plan, got nil")
}
if !strings.Contains(strings.ToLower(plan.Message), "snapshot") {
t.Errorf("storage template message missing snapshot reference: %q", plan.Message)
}
}
func TestBuildActionPlanForFinding_VMDiskTemplateMessageMentionsExpand(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-vm-1",
ResourceID: "node-a/qemu/101",
ResourceName: "appserver",
ResourceType: "qemu",
Metric: "disk_usage_percent",
CurrentValue: 89.1,
PredictedValue: 95.0,
ThresholdValue: 90.0,
})
if plan == nil {
t.Fatal("expected plan, got nil")
}
if !strings.Contains(strings.ToLower(plan.Message), "expand") {
t.Errorf("vm disk template message missing expand verb: %q", plan.Message)
}
}
func TestBuildActionPlanForFinding_NormalizesCaseAndWhitespace(t *testing.T) {
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-norm",
ResourceType: " STORAGE ",
Metric: " Usage_Percent ",
CurrentValue: 80,
})
if plan == nil {
t.Fatal("expected plan after case/whitespace normalization, got nil")
}
}
func TestBuildActionPlanForFinding_RespectsExplicitNow(t *testing.T) {
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
plan := BuildActionPlanForFinding(CapacityFindingInput{
FindingID: "f-now",
ResourceType: "storage",
Metric: "usage_percent",
CurrentValue: 80,
Now: now,
})
if plan == nil {
t.Fatal("expected plan, got nil")
}
if !plan.PlannedAt.Equal(now) {
t.Errorf("PlannedAt = %v; want %v (caller's Now must be honored for deterministic tests)", plan.PlannedAt, now)
}
wantExpiry := now.Add(proposalTTL)
if !plan.ExpiresAt.Equal(wantExpiry) {
t.Errorf("ExpiresAt = %v; want %v", plan.ExpiresAt, wantExpiry)
}
}
func TestHasCapacityActionTemplate(t *testing.T) {
cases := []struct {
name string
resourceType string
metric string
want bool
}{
{"pbs-datastore-usage", "pbs-datastore", "usage_percent", true},
{"storage-usage", "storage", "usage_percent", true},
{"qemu-disk", "qemu", "disk_usage_percent", true},
{"vm-disk-alias", "vm", "disk_usage_percent", true},
{"lxc-disk", "lxc", "disk_usage_percent", true},
{"system-container-disk-alias", "system-container", "disk_usage_percent", true},
{"unknown-pair", "node", "uptime_seconds", false},
{"capacity-cpu-not-registered", "qemu", "cpu_percent", false},
{"empty-strings", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HasCapacityActionTemplate(tc.resourceType, tc.metric)
if got != tc.want {
t.Errorf("HasCapacityActionTemplate(%q, %q) = %v; want %v", tc.resourceType, tc.metric, got, tc.want)
}
})
}
}
func TestCapacityActionPlanSourceIsStable(t *testing.T) {
// The frontend FindingsPanel.tsx looks for this exact string to
// distinguish the capacity-forecast approval card from the generic
// remediation plan card. If you change this constant, update the
// frontend at the same time.
if CapacityActionPlanSource != "capacity_forecast" {
t.Fatalf("CapacityActionPlanSource = %q; frontend depends on the literal %q", CapacityActionPlanSource, "capacity_forecast")
}
}
+1 -3
View File
@@ -13,15 +13,13 @@
// ▼
// recordFinding() ── dedup, threshold validation
// │
// ──▶ MaybeInvestigateFinding() ── autonomous investigation
// ──▶ MaybeInvestigateFinding() ── autonomous investigation
// │ │
// │ ▼
// │ parseInvestigationSummary() ── extract PROPOSED_FIX
// │ │
// │ ▼
// │ approval / execution / verification
// │
// └──▶ generateRemediationPlan() ── template-based fix plan
//
// Safety: All command execution goes through internal/ai/safety for
// blocked command detection. Investigation guardrails and remediation
+17 -18
View File
@@ -2,7 +2,6 @@ package ai
import (
"context"
"strings"
"testing"
"time"
@@ -10,7 +9,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestShouldResolveAlert_UsageAndMissing(t *testing.T) {
func TestShouldResolveAlert_DoesNotResolveUsageWithoutModel(t *testing.T) {
ps := NewPatrolService(nil, nil)
now := time.Now()
@@ -26,8 +25,8 @@ func TestShouldResolveAlert_UsageAndMissing(t *testing.T) {
Storage: []models.Storage{{ID: "storage-1", Usage: 80}},
}
resolved, reason := ps.shouldResolveAlertState(context.Background(), usageAlert, patrolRuntimeStateForTest(ps, state), nil, "")
if !resolved || !strings.Contains(reason, "usage dropped") {
t.Fatalf("expected usage alert to resolve, got resolved=%v reason=%q", resolved, reason)
if resolved || reason != "" {
t.Fatalf("expected usage alert to remain unresolved without model review, got resolved=%v reason=%q", resolved, reason)
}
missingAlert := AlertInfo{
@@ -39,12 +38,12 @@ func TestShouldResolveAlert_UsageAndMissing(t *testing.T) {
StartTime: now.Add(-25 * time.Hour),
}
resolved, reason = ps.shouldResolveAlertState(context.Background(), missingAlert, patrolRuntimeStateForTest(ps, state), nil, "")
if !resolved || !strings.Contains(reason, "resource no longer present") {
t.Fatalf("expected missing storage alert to resolve, got resolved=%v reason=%q", resolved, reason)
if resolved || reason != "" {
t.Fatalf("expected missing storage alert to remain unresolved without model review, got resolved=%v reason=%q", resolved, reason)
}
}
func TestShouldResolveAlert_UsageUsesReadState(t *testing.T) {
func TestShouldResolveAlert_ReadStateDoesNotResolveWithoutModel(t *testing.T) {
ps := NewPatrolService(nil, nil)
now := time.Now()
alert := AlertInfo{
@@ -62,12 +61,12 @@ func TestShouldResolveAlert_UsageUsesReadState(t *testing.T) {
})
resolved, reason := ps.shouldResolveAlertState(context.Background(), alert, patrolRuntimeState{readState: registry}, nil, "")
if !resolved || !strings.Contains(reason, "usage dropped") {
t.Fatalf("expected usage alert to resolve from readState, got resolved=%v reason=%q", resolved, reason)
if resolved || reason != "" {
t.Fatalf("expected readState alert to remain unresolved without model review, got resolved=%v reason=%q", resolved, reason)
}
}
func TestShouldResolveAlert_CPUAndOffline(t *testing.T) {
func TestShouldResolveAlert_CPUAndOfflineDoNotResolveWithoutModel(t *testing.T) {
ps := NewPatrolService(nil, nil)
now := time.Now()
@@ -85,8 +84,8 @@ func TestShouldResolveAlert_CPUAndOffline(t *testing.T) {
Nodes: []models.Node{{ID: "node-1", Name: "node-1", CPU: 0.10, Status: "online"}},
}
resolved, reason := ps.shouldResolveAlertState(context.Background(), cpuAlert, patrolRuntimeStateForTest(ps, state), nil, "")
if !resolved || !strings.Contains(reason, "cpu dropped") {
t.Fatalf("expected cpu alert to resolve, got resolved=%v reason=%q", resolved, reason)
if resolved || reason != "" {
t.Fatalf("expected cpu alert to remain unresolved without model review, got resolved=%v reason=%q", resolved, reason)
}
offlineAlert := AlertInfo{
@@ -99,8 +98,8 @@ func TestShouldResolveAlert_CPUAndOffline(t *testing.T) {
}
state.VMs = []models.VM{{ID: "vm-1", Name: "vm-1", Status: "running"}}
resolved, reason = ps.shouldResolveAlertState(context.Background(), offlineAlert, patrolRuntimeStateForTest(ps, state), nil, "")
if !resolved || !strings.Contains(reason, "resource is now online") {
t.Fatalf("expected offline alert to resolve, got resolved=%v reason=%q", resolved, reason)
if resolved || reason != "" {
t.Fatalf("expected offline alert to remain unresolved without model review, got resolved=%v reason=%q", resolved, reason)
}
}
@@ -131,10 +130,10 @@ func TestReviewAndResolveAlerts(t *testing.T) {
state := models.StateSnapshot{Storage: []models.Storage{{ID: "storage-1", Usage: 70}}}
resolved := ps.reviewAndResolveAlertsState(context.Background(), patrolRuntimeStateForTest(ps, state), false, "")
if resolved != 1 {
t.Fatalf("expected 1 alert resolved, got %d", resolved)
if resolved != 0 {
t.Fatalf("expected no alert resolution without model review, got %d", resolved)
}
if len(resolver.clears) != 1 || resolver.clears[0] != "stale" {
t.Fatalf("expected stale alert to be resolved, got %v", resolver.clears)
if len(resolver.clears) != 0 {
t.Fatalf("expected no alerts to be resolved, got %v", resolver.clears)
}
}
+5 -276
View File
@@ -1,5 +1,5 @@
// patrol_findings.go manages the finding lifecycle: creation, resolution, dismissal,
// remediation plan generation, investigation triggering and verification,
// investigation-triggered action artifact capture and verification,
// and the adapter types that bridge patrol findings to the investigation subsystem.
package ai
@@ -56,11 +56,10 @@ func (p *PatrolService) recordFinding(f *Finding) bool {
Str("title", stored.Title).
Msg("AI Patrol: New finding")
// Generate remediation plan for actionable findings
// Skip internal error findings (not actionable by users)
if !patrolFindingUsesSyntheticRuntimeResource(stored) {
p.generateRemediationPlan(stored)
}
// Findings are evidence. Pulse does not manufacture remediation plans
// from category/title heuristics; the selected model owns diagnosis and
// remediation reasoning, with Pulse enforcing approval/verification gates
// after the model proposes an action.
// Send push notification for new critical/warning findings
if stored.Severity == FindingSeverityCritical || stored.Severity == FindingSeverityWarning {
@@ -129,92 +128,6 @@ func (p *PatrolService) clearBlockedReason() {
p.mu.Unlock()
}
// generateRemediationPlan creates a remediation plan for a finding if appropriate.
// Only generates plans for critical/warning findings when a remediation engine is configured.
func (p *PatrolService) generateRemediationPlan(finding *Finding) {
p.mu.RLock()
engine := p.remediationEngine
p.mu.RUnlock()
if engine == nil {
return
}
// Only generate plans for actionable findings
if finding.Severity != FindingSeverityCritical && finding.Severity != FindingSeverityWarning {
return
}
// Generate remediation steps based on finding category and resource type
steps := p.generateRemediationSteps(finding)
if len(steps) == 0 {
return
}
// Determine risk level based on finding severity and category
riskLevel := aicontracts.RiskLow
if finding.Severity == FindingSeverityWarning {
riskLevel = aicontracts.RiskMedium
}
if finding.Severity == FindingSeverityCritical {
riskLevel = aicontracts.RiskHigh
}
// Reliability issues involving restarts/reboots are higher risk
if finding.Category == FindingCategoryReliability {
title := strings.ToLower(finding.Title)
if strings.Contains(title, "restart") || strings.Contains(title, "reboot") || strings.Contains(title, "offline") {
if riskLevel < aicontracts.RiskHigh {
riskLevel = aicontracts.RiskHigh
}
} else if riskLevel < aicontracts.RiskMedium {
riskLevel = aicontracts.RiskMedium
}
}
// Create the remediation plan
plan := &aicontracts.RemediationPlan{
FindingID: finding.ID,
ResourceID: finding.ResourceID,
Title: fmt.Sprintf("Fix: %s", finding.Title),
Description: finding.Description,
Category: aicontracts.CategoryGuided, // All auto-generated plans require user approval
RiskLevel: riskLevel,
Steps: steps,
Rationale: finding.Recommendation,
}
// Add warnings based on risk level
if riskLevel == aicontracts.RiskHigh {
plan.Warnings = append(plan.Warnings, "High risk: This action may cause service disruption. Review carefully and consider scheduling during maintenance window.")
} else if riskLevel == aicontracts.RiskMedium {
plan.Warnings = append(plan.Warnings, "Review steps carefully before execution")
}
// Capacity-forecast proposal attachment: when a capacity finding has
// a registered deterministic action template, attach the proposal so
// FindingsPanel can render the forecast approval card. RequiresApproval
// is invariant on the template side; we never auto-execute.
if proposal := buildCapacityActionProposal(finding); proposal != nil {
plan.ProposedActionPlan = proposal
}
if err := engine.CreatePlan(plan); err != nil {
log.Debug().
Err(err).
Str("finding_id", finding.ID).
Str("resource", finding.ResourceName).
Msg("AI Patrol: Failed to create remediation plan")
return
}
log.Info().
Str("plan_id", plan.ID).
Str("finding_id", finding.ID).
Str("resource", finding.ResourceName).
Int("steps", len(steps)).
Msg("AI Patrol: Remediation plan generated")
}
// generateRemediationPlanFromInvestigation persists a remediation plan artifact when
// an investigation proposes a concrete fix command. This is intentionally separate
// from the "approval" execution pipeline; it's a durable summary users can act on
@@ -342,190 +255,6 @@ func (p *PatrolService) generateRemediationPlanFromInvestigation(findingID strin
}
}
// generateRemediationSteps creates appropriate steps based on finding type
func (p *PatrolService) generateRemediationSteps(finding *Finding) []aicontracts.RemediationStep {
var steps []aicontracts.RemediationStep
switch finding.Category {
case FindingCategoryPerformance:
steps = p.generatePerformanceSteps(finding)
case FindingCategoryCapacity:
steps = p.generateCapacitySteps(finding)
case FindingCategoryReliability:
steps = p.generateAvailabilitySteps(finding)
case FindingCategoryBackup:
steps = p.generateBackupSteps(finding)
case FindingCategorySecurity:
steps = p.generateSecuritySteps(finding)
case FindingCategoryGeneral:
steps = p.generateConfigurationSteps(finding)
default:
// Generic investigation steps for unknown categories
steps = []aicontracts.RemediationStep{
{Order: 1, Description: "Investigate the issue by reviewing current resource state"},
{Order: 2, Description: "Review recent changes that may have caused this issue"},
{Order: 3, Description: "Take appropriate corrective action based on findings"},
}
}
return steps
}
// generatePerformanceSteps creates steps for performance issues
func (p *PatrolService) generatePerformanceSteps(finding *Finding) []aicontracts.RemediationStep {
title := strings.ToLower(finding.Title)
if strings.Contains(title, "cpu") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Identify processes consuming excessive CPU", Target: finding.ResourceID},
{Order: 2, Description: "Check if resource needs more CPU cores allocated"},
{Order: 3, Description: "Consider migrating to a less loaded host if VM/container"},
{Order: 4, Description: "Optimize or restart resource-hungry applications"},
}
}
if strings.Contains(title, "memory") || strings.Contains(title, "ram") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Identify processes consuming excessive memory", Target: finding.ResourceID},
{Order: 2, Description: "Check for memory leaks in running applications"},
{Order: 3, Description: "Consider increasing allocated memory"},
{Order: 4, Description: "Restart affected services to reclaim memory"},
}
}
if strings.Contains(title, "io") || strings.Contains(title, "disk") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Identify processes causing high disk I/O", Target: finding.ResourceID},
{Order: 2, Description: "Check for runaway log files or heavy writes"},
{Order: 3, Description: "Consider migrating to faster storage"},
}
}
// Generic performance steps
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review current resource utilization metrics", Target: finding.ResourceID},
{Order: 2, Description: "Identify performance bottlenecks"},
{Order: 3, Description: "Optimize resource allocation or application configuration"},
}
}
// generateCapacitySteps creates steps for capacity issues
func (p *PatrolService) generateCapacitySteps(finding *Finding) []aicontracts.RemediationStep {
title := strings.ToLower(finding.Title)
if strings.Contains(title, "disk") || strings.Contains(title, "storage") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Identify largest files and directories consuming space", Target: finding.ResourceID},
{Order: 2, Description: "Clean up temporary files, logs, and caches"},
{Order: 3, Description: "Remove unused packages and old kernels"},
{Order: 4, Description: "Consider expanding disk or adding additional storage"},
}
}
if strings.Contains(title, "memory") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review memory allocation across workloads", Target: finding.ResourceID},
{Order: 2, Description: "Reduce memory allocation on over-provisioned VMs"},
{Order: 3, Description: "Add more physical memory to the host"},
}
}
// Generic capacity steps
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review current capacity utilization", Target: finding.ResourceID},
{Order: 2, Description: "Identify growth trends and plan for expansion"},
{Order: 3, Description: "Clean up unused resources to free capacity"},
}
}
// generateAvailabilitySteps creates steps for availability issues
func (p *PatrolService) generateAvailabilitySteps(finding *Finding) []aicontracts.RemediationStep {
title := strings.ToLower(finding.Title)
if strings.Contains(title, "offline") || strings.Contains(title, "down") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Verify network connectivity to the resource", Target: finding.ResourceID},
{Order: 2, Description: "Check host status if this is a VM/container"},
{Order: 3, Description: "Review system logs for crash or shutdown reasons"},
{Order: 4, Description: "Attempt to start or restart the resource"},
}
}
if strings.Contains(title, "restart") || strings.Contains(title, "reboot") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review system logs for cause of restarts", Target: finding.ResourceID},
{Order: 2, Description: "Check for OOM kills or kernel panics"},
{Order: 3, Description: "Investigate application crashes"},
{Order: 4, Description: "Consider enabling watchdog or health checks"},
}
}
// Generic availability steps
return []aicontracts.RemediationStep{
{Order: 1, Description: "Verify resource health and connectivity", Target: finding.ResourceID},
{Order: 2, Description: "Review recent events and logs"},
{Order: 3, Description: "Take corrective action to restore availability"},
}
}
// generateBackupSteps creates steps for backup-related issues
func (p *PatrolService) generateBackupSteps(finding *Finding) []aicontracts.RemediationStep {
title := strings.ToLower(finding.Title)
if strings.Contains(title, "missing") || strings.Contains(title, "no backup") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Verify backup job configuration exists", Target: finding.ResourceID},
{Order: 2, Description: "Check backup storage availability and capacity"},
{Order: 3, Description: "Create or enable backup schedule"},
{Order: 4, Description: "Run initial backup job"},
}
}
if strings.Contains(title, "failed") || strings.Contains(title, "error") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review backup job logs for error details", Target: finding.ResourceID},
{Order: 2, Description: "Check backup storage connectivity and space"},
{Order: 3, Description: "Verify backup credentials and permissions"},
{Order: 4, Description: "Retry backup job after fixing issues"},
}
}
if strings.Contains(title, "old") || strings.Contains(title, "stale") || strings.Contains(title, "outdated") {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Check why scheduled backups are not running", Target: finding.ResourceID},
{Order: 2, Description: "Review backup retention policy"},
{Order: 3, Description: "Trigger a new backup immediately"},
}
}
// Generic backup steps
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review backup configuration and schedule", Target: finding.ResourceID},
{Order: 2, Description: "Verify backup storage health"},
{Order: 3, Description: "Ensure backup jobs are running successfully"},
}
}
// generateConfigurationSteps creates steps for configuration issues
func (p *PatrolService) generateConfigurationSteps(finding *Finding) []aicontracts.RemediationStep {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Review current configuration settings", Target: finding.ResourceID},
{Order: 2, Description: "Compare against recommended best practices"},
{Order: 3, Description: "Apply configuration changes as needed"},
{Order: 4, Description: "Verify changes don't impact dependent services"},
}
}
// generateSecuritySteps creates steps for security issues
func (p *PatrolService) generateSecuritySteps(finding *Finding) []aicontracts.RemediationStep {
return []aicontracts.RemediationStep{
{Order: 1, Description: "Assess the security impact and urgency", Target: finding.ResourceID},
{Order: 2, Description: "Review access logs for suspicious activity"},
{Order: 3, Description: "Apply security patches or configuration fixes"},
{Order: 4, Description: "Verify remediation and update security policies"},
}
}
// GetFindingsForResource returns active findings for a specific resource
func (p *PatrolService) GetFindingsForResource(resourceID string) []*Finding {
findings := p.findings.GetByResource(resourceID)
+6 -52
View File
@@ -907,49 +907,7 @@ func TestIsActionable_EscapeHatchesPreserved(t *testing.T) {
}
}
func TestPatrolService_GenerateRemediationSteps(t *testing.T) {
ps := NewPatrolService(nil, nil)
cases := []struct {
name string
category FindingCategory
title string
wantLen int
}{
{name: "performance-cpu", category: FindingCategoryPerformance, title: "High CPU usage", wantLen: 4},
{name: "capacity-disk", category: FindingCategoryCapacity, title: "Disk space low", wantLen: 4},
{name: "reliability-offline", category: FindingCategoryReliability, title: "Service offline", wantLen: 4},
{name: "backup-failed", category: FindingCategoryBackup, title: "Backup failed", wantLen: 4},
{name: "security", category: FindingCategorySecurity, title: "Vulnerability detected", wantLen: 4},
{name: "general", category: FindingCategoryGeneral, title: "Config drift detected", wantLen: 4},
}
for _, c := range cases {
finding := &Finding{
ID: "f-" + c.name,
ResourceID: "res-1",
Category: c.category,
Title: c.title,
}
steps := ps.generateRemediationSteps(finding)
if len(steps) != c.wantLen {
t.Fatalf("%s: expected %d steps, got %d", c.name, c.wantLen, len(steps))
}
}
unknown := &Finding{
ID: "f-unknown",
ResourceID: "res-1",
Category: FindingCategory("mystery"),
Title: "Unknown issue",
}
steps := ps.generateRemediationSteps(unknown)
if len(steps) != 3 {
t.Fatalf("unknown category: expected 3 generic steps, got %d", len(steps))
}
}
func TestPatrolService_GenerateRemediationPlan(t *testing.T) {
func TestPatrolService_RecordFindingDoesNotGenerateTemplateRemediationPlan(t *testing.T) {
engine := newTestRemediationEngine()
ps := NewPatrolService(nil, nil)
ps.SetRemediationEngine(engine)
@@ -967,17 +925,13 @@ func TestPatrolService_GenerateRemediationPlan(t *testing.T) {
Recommendation: "Investigate restart cause",
}
ps.generateRemediationPlan(finding)
if !ps.recordFinding(finding) {
t.Fatal("expected finding to be recorded as new")
}
plan := engine.GetPlanForFinding(finding.ID)
if plan == nil {
t.Fatal("expected remediation plan to be created")
}
if plan.RiskLevel == "" {
t.Fatal("expected risk level to be set on plan")
}
if len(plan.Warnings) == 0 {
t.Fatal("expected warnings to be added to plan")
if plan != nil {
t.Fatalf("unexpected Pulse-authored remediation plan generated: %+v", plan)
}
}
@@ -1,191 +0,0 @@
// patrol_findings_capacity_proposal.go: glue between the patrol finding
// pipeline and the deterministic forecast-driven action templates in
// internal/ai/forecast.
//
// Lives in its own file (rather than in patrol_findings.go) because the
// projection is self-contained and easy to grow as more capacity-forecast
// resource types come online (e.g. PVE node disk, Ceph OSD usage). Adding a
// new resource type generally means: (1) register a template in
// capacity_action_templates.go, (2) extend metricForCapacityFinding /
// resourceTypeForCapacityFinding here, and that's it.
package ai
import (
"regexp"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/forecast"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
// percentInTitle pulls a leading percentage value out of a finding title
// such as "Storage pool tank at 87.3% usage". This is best-effort - if a
// future signal detector phrases its title differently, the proposal still
// renders, just without a current-value snapshot.
var percentInTitle = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*%`)
// buildCapacityActionProposal returns a wire-side ProposedActionPlan when
// the finding is a capacity finding for a resource type that has a
// registered template, or nil otherwise. The pipeline calls this before
// engine.CreatePlan so the proposal lands on the same RemediationPlan that
// FindingsPanel already loads from /api/ai/remediation/plans.
//
// Read-side and approval-gated. The template registry guarantees
// RequiresApproval=true; this helper additionally guarantees Allowed
// flows through unchanged (no escalation here).
func buildCapacityActionProposal(finding *Finding) *aicontracts.ProposedActionPlan {
if finding == nil {
return nil
}
if finding.Category != FindingCategoryCapacity {
return nil
}
resourceType := normalizedCapacityResourceType(finding)
metric := metricForCapacityFinding(finding, resourceType)
if !forecast.HasCapacityActionTemplate(resourceType, metric) {
return nil
}
currentValue := extractCurrentValue(finding)
// PredictedValue / TimeToThreshold are best-effort: the forecast
// service is available on the patrol service via SetForecastService
// in some deployments, but the wire-in must not depend on it being
// configured. When unavailable, we surface the current value alone -
// the finding crossing threshold is itself sufficient signal to
// propose remediation per the lane brief.
predictedValue := currentValue
thresholdValue := capacityThresholdForFinding(resourceType)
plan := forecast.BuildActionPlanForFinding(forecast.CapacityFindingInput{
FindingID: finding.ID,
ResourceID: finding.ResourceID,
ResourceName: finding.ResourceName,
ResourceType: resourceType,
Node: finding.Node,
Metric: metric,
CurrentValue: currentValue,
PredictedValue: predictedValue,
ThresholdValue: thresholdValue,
Now: time.Now().UTC(),
})
if plan == nil {
return nil
}
return projectCapacityProposal(plan, metric, currentValue, predictedValue, thresholdValue)
}
// normalizedCapacityResourceType maps the finding's ResourceType to a
// template-registry key. Findings carry resource_type values in
// historical conventions (storage, vm, system-container, pbs); we leave
// them as-is and let the registry's lower-case lookup do the matching.
func normalizedCapacityResourceType(finding *Finding) string {
rt := strings.TrimSpace(finding.ResourceType)
return rt
}
// metricForCapacityFinding returns the metric name the registry expects
// for the given resource type. Capacity findings today don't carry an
// explicit metric field, so we pick the canonical one for the resource
// type. Storage/PBS use usage_percent; guests use disk_usage_percent.
func metricForCapacityFinding(_ *Finding, resourceType string) string {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "storage", "pbs", "pbs-datastore":
return "usage_percent"
case "vm", "qemu", "lxc", "system-container":
return "disk_usage_percent"
default:
return ""
}
}
// capacityThresholdForFinding returns the warning threshold Pulse uses to
// flag a resource as a capacity concern. We surface this on the proposal
// so the operator sees the same threshold that triggered the finding.
//
// These values mirror SignalThresholds defaults in patrol_signals.go;
// keeping the literal here avoids a dependency on the running
// PatrolService config and matches the brief's "or the finding evidence
// already exceeds a configured threshold" path.
func capacityThresholdForFinding(resourceType string) float64 {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "storage", "pbs", "pbs-datastore":
return 75.0
case "vm", "qemu", "lxc", "system-container":
return 85.0
default:
return 0
}
}
// extractCurrentValue parses the trailing "X.Y%" out of a capacity
// finding title. The signal detectors in patrol_signals.go consistently
// format these as "Storage pool tank at 87.3% usage" / "High disk usage on
// foo: 92.1%". When parsing fails we return 0; the proposal still renders.
func extractCurrentValue(finding *Finding) float64 {
if finding == nil {
return 0
}
matches := percentInTitle.FindStringSubmatch(finding.Title)
if len(matches) < 2 {
matches = percentInTitle.FindStringSubmatch(finding.Description)
}
if len(matches) < 2 {
return 0
}
v, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0
}
return v
}
// projectCapacityProposal converts a unifiedresources.ActionPlan (the
// shape templates produce, suitable for the action engine) into the
// aicontracts.ProposedActionPlan wire projection that rides on the
// RemediationPlan to the frontend.
//
// We deliberately keep the projection narrow (no execution params, no
// resource version hashes) because the frontend only needs enough to
// render a card. The full ActionPlan is re-derived at execute time.
func projectCapacityProposal(
plan *unifiedresources.ActionPlan,
metric string,
currentValue, predictedValue, thresholdValue float64,
) *aicontracts.ProposedActionPlan {
if plan == nil {
return nil
}
out := &aicontracts.ProposedActionPlan{
ActionID: plan.ActionID,
Allowed: plan.Allowed,
RequiresApproval: plan.RequiresApproval,
ApprovalPolicy: string(plan.ApprovalPolicy),
Message: plan.Message,
Source: forecast.CapacityActionPlanSource,
PlannedAt: plan.PlannedAt,
ExpiresAt: plan.ExpiresAt,
ProjectedMetric: &aicontracts.ProposedMetricSummary{
Metric: metric,
CurrentValue: currentValue,
PredictedValue: predictedValue,
ThresholdValue: thresholdValue,
},
}
if plan.Preflight != nil {
out.Preflight = &aicontracts.ProposedActionPreflight{
Target: plan.Preflight.Target,
CurrentState: plan.Preflight.CurrentState,
IntendedChange: plan.Preflight.IntendedChange,
DryRunAvailable: plan.Preflight.DryRunAvailable,
DryRunSummary: plan.Preflight.DryRunSummary,
SafetyChecks: append([]string(nil), plan.Preflight.SafetyChecks...),
VerificationSteps: append([]string(nil), plan.Preflight.VerificationSteps...),
}
}
return out
}
@@ -1,203 +0,0 @@
package ai
import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/forecast"
)
func TestBuildCapacityActionProposal_AttachesProposalForCapacityFinding(t *testing.T) {
finding := &Finding{
ID: "f-cap-1",
Severity: FindingSeverityWarning,
Category: FindingCategoryCapacity,
ResourceID: "node-a/storage/tank",
ResourceName: "tank",
ResourceType: "storage",
Title: "Storage pool tank at 87.3% usage",
}
proposal := buildCapacityActionProposal(finding)
if proposal == nil {
t.Fatal("expected ProposedActionPlan for storage capacity finding, got nil")
}
if !proposal.RequiresApproval {
t.Error("RequiresApproval = false; capacity proposals must always be approval-gated")
}
if proposal.Allowed {
t.Error("Allowed = true; current templates ship preflight-only (Allowed=false)")
}
if proposal.Source != forecast.CapacityActionPlanSource {
t.Errorf("Source = %q; want %q so the frontend renders the capacity-forecast card", proposal.Source, forecast.CapacityActionPlanSource)
}
if proposal.ProjectedMetric == nil {
t.Fatal("ProjectedMetric must be populated so the operator sees current/projected/threshold")
}
if proposal.ProjectedMetric.Metric != "usage_percent" {
t.Errorf("ProjectedMetric.Metric = %q; want usage_percent for storage", proposal.ProjectedMetric.Metric)
}
if proposal.ProjectedMetric.CurrentValue == 0 {
t.Error("ProjectedMetric.CurrentValue = 0; expected 87.3 parsed from finding title")
}
if proposal.Preflight == nil {
t.Fatal("Preflight projection must be present so the card surfaces SafetyChecks")
}
if len(proposal.Preflight.SafetyChecks) == 0 {
t.Error("Preflight.SafetyChecks must round-trip from the template")
}
if proposal.ActionID == "" || !strings.HasPrefix(proposal.ActionID, "capacity-forecast-") {
t.Errorf("ActionID = %q; want capacity-forecast- prefix", proposal.ActionID)
}
}
func TestBuildCapacityActionProposal_ReturnsNilForNonCapacityFinding(t *testing.T) {
cases := []struct {
name string
category FindingCategory
}{
{"performance", FindingCategoryPerformance},
{"reliability", FindingCategoryReliability},
{"backup", FindingCategoryBackup},
{"security", FindingCategorySecurity},
{"general", FindingCategoryGeneral},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
finding := &Finding{
ID: "f-noncap",
Severity: FindingSeverityWarning,
Category: tc.category,
ResourceID: "node-a/storage/tank",
ResourceName: "tank",
ResourceType: "storage",
Title: "Storage pool tank at 87.3% usage",
}
if proposal := buildCapacityActionProposal(finding); proposal != nil {
t.Fatalf("expected nil proposal for category %s; got %+v", tc.category, proposal)
}
})
}
}
func TestBuildCapacityActionProposal_ReturnsNilForUnregisteredResourceType(t *testing.T) {
finding := &Finding{
ID: "f-cap-unknown",
Severity: FindingSeverityWarning,
Category: FindingCategoryCapacity,
ResourceID: "agent/raid/0",
ResourceName: "controller-0",
ResourceType: "agent_raid",
Title: "RAID controller has no capacity template",
}
if proposal := buildCapacityActionProposal(finding); proposal != nil {
t.Fatalf("expected nil proposal for unregistered resource type; got %+v", proposal)
}
}
func TestBuildCapacityActionProposal_VMDiskFindingMatchesGuestTemplate(t *testing.T) {
finding := &Finding{
ID: "f-vm-cap",
Severity: FindingSeverityCritical,
Category: FindingCategoryCapacity,
ResourceID: "node-a/qemu/101",
ResourceName: "appserver",
ResourceType: "qemu",
Title: "appserver disk at 95.2% used",
}
proposal := buildCapacityActionProposal(finding)
if proposal == nil {
t.Fatal("expected proposal for qemu disk finding, got nil")
}
if proposal.ProjectedMetric == nil || proposal.ProjectedMetric.Metric != "disk_usage_percent" {
t.Errorf("ProjectedMetric.Metric = %v; want disk_usage_percent for guest disk", proposal.ProjectedMetric)
}
if proposal.ProjectedMetric.CurrentValue == 0 {
t.Error("CurrentValue = 0; expected parsed value from title")
}
}
func TestGenerateRemediationPlan_AttachesProposalForCapacityFinding(t *testing.T) {
ps := NewPatrolService(nil, nil)
engine := newTestRemediationEngine()
ps.SetRemediationEngine(engine)
finding := &Finding{
ID: "f-gen-cap",
Severity: FindingSeverityWarning,
Category: FindingCategoryCapacity,
ResourceID: "node-a/storage/tank",
ResourceName: "tank",
ResourceType: "storage",
Title: "Storage pool tank at 88.2% usage",
Description: "Tank pool exceeded warning threshold.",
DetectedAt: time.Now(),
}
ps.generateRemediationPlan(finding)
plan := engine.GetPlanForFinding(finding.ID)
if plan == nil {
t.Fatal("expected remediation plan to be created for capacity finding")
}
if plan.ProposedActionPlan == nil {
t.Fatal("RemediationPlan.ProposedActionPlan = nil; capacity finding should attach a proposal via the forecast template registry")
}
if !plan.ProposedActionPlan.RequiresApproval {
t.Error("attached proposal must have RequiresApproval=true")
}
if plan.ProposedActionPlan.Source != forecast.CapacityActionPlanSource {
t.Errorf("attached proposal Source = %q; want %q", plan.ProposedActionPlan.Source, forecast.CapacityActionPlanSource)
}
}
func TestGenerateRemediationPlan_DoesNotAttachProposalForNonCapacityFinding(t *testing.T) {
ps := NewPatrolService(nil, nil)
engine := newTestRemediationEngine()
ps.SetRemediationEngine(engine)
finding := &Finding{
ID: "f-gen-perf",
Severity: FindingSeverityWarning,
Category: FindingCategoryPerformance,
ResourceID: "node-a/qemu/101",
ResourceName: "appserver",
ResourceType: "qemu",
Title: "High CPU on appserver: 92.0%",
Description: "Sustained CPU usage above warning threshold.",
DetectedAt: time.Now(),
}
ps.generateRemediationPlan(finding)
plan := engine.GetPlanForFinding(finding.ID)
if plan == nil {
t.Fatal("expected remediation plan to be created for performance finding")
}
if plan.ProposedActionPlan != nil {
t.Fatalf("non-capacity finding must not attach a forecast proposal; got %+v", plan.ProposedActionPlan)
}
}
func TestExtractCurrentValue(t *testing.T) {
cases := []struct {
name string
title string
want float64
}{
{"trailing percent", "Storage pool tank at 87.3% usage", 87.3},
{"colon then percent", "High disk usage on foo: 95%", 95.0},
{"integer percent", "Disk used 100%", 100.0},
{"no percent", "Storage pool tank running low", 0},
{"percent in middle", "appserver disk at 92.5% used", 92.5},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractCurrentValue(&Finding{Title: tc.title})
if got != tc.want {
t.Errorf("extractCurrentValue(%q) = %v; want %v", tc.title, got, tc.want)
}
})
}
}
+4 -36
View File
@@ -2022,7 +2022,6 @@ func (p *PatrolService) reviewAndResolveAlertsState(ctx context.Context, state p
resolvedCount := 0
// Pass nil for aiService if LLM is not allowed (use heuristic checks only).
aiSvc := aiService
if !llmAllowed {
aiSvc = nil
@@ -2052,42 +2051,11 @@ func (p *PatrolService) reviewAndResolveAlertsState(ctx context.Context, state p
return resolvedCount
}
// shouldResolveAlert determines if an alert should be auto-resolved based on current state.
// Returns (shouldResolve, reason)
// shouldResolveAlert determines if an alert should be auto-resolved based on
// model review of current state. Pulse can gather context and enforce the
// resolution gate, but it must not use local alert-type heuristics to decide
// that an infrastructure issue has cleared.
func (p *PatrolService) shouldResolveAlertState(ctx context.Context, alert AlertInfo, snap patrolRuntimeState, aiService *Service, executionID string) (bool, string) {
// First, try smart heuristic checks based on alert type
switch alert.Type {
case "usage": // Storage usage alert
resource := lookupPatrolAlertResourceState(alert, snap)
if resource.found {
if resource.disk < alert.Threshold*0.95 { // 5% margin below threshold
return true, fmt.Sprintf("storage usage dropped from %.1f%% to %.1f%% (threshold: %.1f%%)",
alert.Value, resource.disk, alert.Threshold)
}
return false, ""
}
// Storage not found in current snapshot - might have been removed
// Resolve after 24 hours if resource is gone
if time.Since(alert.StartTime) > 24*time.Hour {
return true, "resource no longer present in infrastructure"
}
case "cpu", "memory": // Resource utilization alerts
// Check if this is a node, VM, container, or docker container
currentValue := p.getCurrentMetricValueState(alert, snap)
if currentValue >= 0 && currentValue < alert.Threshold*0.95 {
return true, fmt.Sprintf("%s dropped from %.1f%% to %.1f%% (threshold: %.1f%%)",
alert.Type, alert.Value, currentValue, alert.Threshold)
}
case "offline", "stopped", "docker-offline":
// Check if the resource is now online
if p.isResourceOnlineState(alert, snap) {
return true, "resource is now online/running"
}
}
// For complex cases or when heuristics don't apply, use AI judgment if available
if aiService != nil && aiService.IsEnabled() {
return p.askAIAboutAlertState(ctx, alert, snap, aiService, executionID)
}
+10 -16
View File
@@ -1948,9 +1948,9 @@ func TestAlertResolutionHelpers_UseReadStateForAppContainer(t *testing.T) {
}
}
// --- shouldResolveAlert (heuristic-only, no AI) ---
// --- shouldResolveAlert (model-owned, no local auto-resolution without AI) ---
func TestShouldResolveAlert_StorageUsageDropped(t *testing.T) {
func TestShouldResolveAlert_StorageUsageDroppedDoesNotResolveWithoutAI(t *testing.T) {
ps := NewPatrolService(nil, nil)
state := models.StateSnapshot{
Storage: []models.Storage{
@@ -1969,15 +1969,12 @@ func TestShouldResolveAlert_StorageUsageDropped(t *testing.T) {
}
shouldResolve, reason := ps.shouldResolveAlertState(nil, alert, patrolRuntimeStateForTest(ps, state), nil, "")
if !shouldResolve {
t.Error("expected alert to be resolved (usage dropped below threshold)")
}
if reason == "" {
t.Error("expected a reason string")
if shouldResolve || reason != "" {
t.Errorf("expected alert to remain unresolved without AI review, got resolved=%v reason=%q", shouldResolve, reason)
}
}
func TestShouldResolveAlert_CPUDropped(t *testing.T) {
func TestShouldResolveAlert_CPUDroppedDoesNotResolveWithoutAI(t *testing.T) {
ps := NewPatrolService(nil, nil)
state := models.StateSnapshot{
Nodes: []models.Node{
@@ -1996,12 +1993,12 @@ func TestShouldResolveAlert_CPUDropped(t *testing.T) {
}
shouldResolve, _ := ps.shouldResolveAlertState(nil, alert, patrolRuntimeStateForTest(ps, state), nil, "")
if !shouldResolve {
t.Error("expected alert to be resolved (CPU dropped)")
if shouldResolve {
t.Error("expected alert to remain unresolved without AI review")
}
}
func TestShouldResolveAlert_OfflineNowOnline(t *testing.T) {
func TestShouldResolveAlert_OfflineNowOnlineDoesNotResolveWithoutAI(t *testing.T) {
ps := NewPatrolService(nil, nil)
state := models.StateSnapshot{
Nodes: []models.Node{
@@ -2018,11 +2015,8 @@ func TestShouldResolveAlert_OfflineNowOnline(t *testing.T) {
}
shouldResolve, reason := ps.shouldResolveAlertState(nil, alert, patrolRuntimeStateForTest(ps, state), nil, "")
if !shouldResolve {
t.Error("expected offline alert to be resolved (resource now online)")
}
if reason != "resource is now online/running" {
t.Errorf("unexpected reason: %s", reason)
if shouldResolve || reason != "" {
t.Errorf("expected offline alert to remain unresolved without AI review, got resolved=%v reason=%q", shouldResolve, reason)
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ type patrolRuntimeFailure struct {
// for any Patrol runtime failure. The cause varies but the operational
// consequence is constant: while Patrol is not analyzing, alerts continue
// to fire without enrichment.
const patrolRuntimeFailureImpact = "While Patrol cannot analyze, alerts continue to fire without evidence or recommended actions, and AI Intelligence summaries cannot refresh."
const patrolRuntimeFailureImpact = "While Patrol cannot analyze, alerts continue to fire without fresh Patrol evidence, and AI Intelligence summaries cannot refresh."
type PatrolRuntimeFailureDiagnostic struct {
Title string
+1 -1
View File
@@ -183,7 +183,7 @@ func TestUnifiedStore_AddFromAI_PropagatesImpactOnUpdate(t *testing.T) {
Severity: SeverityWarning,
ResourceID: "res-1",
Title: "Pulse Patrol: Provider issue",
Impact: "While Patrol cannot analyze, alerts continue to fire without evidence or recommended actions.",
Impact: "While Patrol cannot analyze, alerts continue to fire without fresh Patrol evidence.",
}
updated, isNew := store.AddFromAI(update)
if isNew {
+9 -16
View File
@@ -78,25 +78,18 @@ type RemediationPlan struct {
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// ProposedActionPlan is an optional deterministic remediation proposal
// attached by the patrol pipeline (currently only forecast-driven
// capacity proposals - see internal/ai/forecast.BuildActionPlanForFinding).
// It is a wire-side projection of unifiedresources.ActionPlan; the
// projection lives here so pkg/aicontracts stays free of a dependency
// on internal/. The frontend renders proposals with Source ==
// "capacity_forecast" as a distinguishable approve/reject card in
// FindingsPanel.tsx; everything else falls back to the generic plan
// surface. Always nil for plans that have no attached proposal -
// remediation behavior is unchanged when the field is absent.
// ProposedActionPlan is an optional governed action proposal projection.
// It is present only when the remediation owner has already produced a
// concrete action artifact; Patrol finding creation must not populate it
// from category/title heuristics. Always nil for plans that have no
// attached action proposal.
ProposedActionPlan *ProposedActionPlan `json:"proposed_action_plan,omitempty"`
}
// ProposedActionPlan is the wire-side projection of a deterministic action
// proposal attached to a RemediationPlan. RequiresApproval is invariant -
// every proposed plan must carry an explicit approval gate. Allowed=false
// signals "no write capability is wired yet; this is a preflight-only
// proposal" - the operator may still approve to record intent, but
// execution is not connected.
// ProposedActionPlan is the wire-side projection of a governed action proposal
// attached to a RemediationPlan. RequiresApproval is invariant - every
// proposed plan must carry an explicit approval gate. Allowed=false signals
// "no write capability is wired yet; this is a preflight-only proposal".
type ProposedActionPlan struct {
ActionID string `json:"actionId"`
CapabilityName string `json:"capabilityName,omitempty"`