mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make Patrol reasoning model-owned
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+43
-261
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user