From cb5ffc2efe2b7d821f59eb26406d0616cf62e23d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:45:49 +0100 Subject: [PATCH] Remove dead Patrol assessment and save-failure handoffs buildPatrolAssessmentAssistantHandoff lost its last caller when the Patrol summary card's Discuss handler was removed in eac7dfe9ef (2026-05-15), and buildPatrolConfigurationFailureHandoff lost its last caller in 27ba85ddc6 (2026-06-23). Since then only their own tests called them. A TypeScript compiler import graph rooted at src/index.tsx confirms no reachable file imports either one. Removing them also drops PatrolInvestigationContextSummary, their input and output types, and the private assessment, recent-change, correlation and save-failure helpers that only they used (found with noUnusedLocals until it reported nothing). Rollup had already tree-shaken all of it: production builds of the parent tree and of this tree are byte-identical across all 187 dist files, and the bundle-size check passes. The presentation helpers the dead code imported keep their live callers. Their test cases go with them. Two cases covered branches the live finding handoff still uses, so they now run through buildPatrolAssistantFindingHandoffActions and buildPatrolAssistantFindingHandoff: a typed pending action without a legacy approval id stays approval-bound, and the finding context qualifies its resource with the record's node and truncates long lines. v8 coverage of the surviving model code matches the pre-removal baseline except one defensive guard the live caller cannot reach. The raw-source guards pinned none of the removed code. The Assistant drawer still restores saved patrol_assessment and patrol_configuration_failure sessions, so that compatibility path stays. The contracts described both handoffs as live producers, and described a Patrol summary card, assessment strip, assessment readout, score chip and header trust line that the page no longer renders. patrol-intelligence, ai-runtime, api-contracts, frontend-primitives, security-privacy, storage-recovery, agent-lifecycle and unified-resources now say what the code does: those handoffs survive only as saved sessions, the drawer, session list and API keep restoring them, rules for a returning summary card, strip or readout are conditional, and handoff_actions and adjacent-boundary clauses name the finding and attention handoffs that send them today. The run-history handoff clauses also claimed the browser sends a [Patrol Run Context] block and scoped resources; it sends the briefing and run identity, and internal/ai/patrol_assistant_handoff.go rebuilds the rest from the run ID. docs/AI.md (and its public mirror) told users the legacy operational score sits in a collapsed supporting-context section. That section and the score are gone; the section now points at Activity > Finding options and history, where check history lives. ai_runtime_docs_policy_test.py, its registered proof, now pins that location and rejects the retired claim. The same file had failed on main since 2789a15fe5 reworded the Patrol mode API sentence to "The API retains `autonomy_level` for compatibility" without updating its pin; the pin now matches that wording. --- docs/AI.md | 9 +- .../v6/internal/subsystems/agent-lifecycle.md | 5 +- .../v6/internal/subsystems/ai-runtime.md | 46 +- .../v6/internal/subsystems/api-contracts.md | 64 +- .../subsystems/frontend-primitives.md | 109 +- .../subsystems/patrol-intelligence.md | 188 ++- .../internal/subsystems/security-privacy.md | 2 +- .../internal/subsystems/storage-recovery.md | 2 +- .../internal/subsystems/unified-resources.md | 3 +- .../6e6cbc66d62863d1.json | 38 + frontend-modern/public/docs/AI.md | 9 +- ...nvestigationContextModel.branchcov.test.ts | 67 -- ...gationContextModel.branchcov0723pm.test.ts | 676 ----------- .../patrolInvestigationContextModel.test.ts | 684 +---------- .../patrol/patrolInvestigationContextModel.ts | 1069 ----------------- .../ai_runtime_docs_policy_test.py | 7 +- 16 files changed, 290 insertions(+), 2688 deletions(-) create mode 100644 frontend-modern/browser-verification/6e6cbc66d62863d1.json diff --git a/docs/AI.md b/docs/AI.md index f23b965ece..b63402daa4 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -709,11 +709,12 @@ Pulse includes a model-context layer that aggregates evidence from AI runtime su | **Knowledge Store** | Persists user annotations and model-safe context | | **Forecast Service** | Estimates capacity trajectories from historical samples | -### Health Scoring +### Check History -Historical Patrol checks and the legacy operational score remain available in -the collapsed supporting-context section. They are not the primary daily -monitoring answer and do not replace the canonical attention queue. +Historical Patrol checks remain available from **Activity > Finding options and +history**, under **History**. Patrol no longer shows the legacy operational +score. Check history is not the primary daily monitoring answer and does not +replace the canonical attention queue. --- diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b8ed3f012b..3334095e31 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4903,8 +4903,9 @@ secondary model context after backend-refreshed finding context; those merged references still must not become agent enrollment, install, readiness, or fleet-command authority. Scoped Assistant handoffs from alert or incident surfaces may also send -bounded `handoff_context`, `handoff_resources`, and Patrol assessment -`handoff_actions` through `/api/ai/chat` without a `finding_id`; +bounded `handoff_context` and `handoff_resources`, and Patrol attention +handoffs may add `handoff_actions`, through `/api/ai/chat` without a +`finding_id`; lifecycle-adjacent surfaces may treat those references as AI/runtime explanation context only, and must not reinterpret them as agent readiness, install status, command reachability, enrollment health, fleet execution diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 69a9617c2b..f68dc022aa 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3792,16 +3792,15 @@ query...`, and `Reading storage...` before streamed tool arguments are presentation must stay compact: source, status, one primary subject, and an optional safe route link. It must not render Patrol-authored remediation steps, evidence chips, command summaries, recommendations, or suggested-prompt - chips as the answer. Patrol assessment handoffs must use the same - `patrol-assessment` target identity for live opens and restored sessions - rather than inheriting retired dashboard context. While such a handoff is - attached, the Assistant empty + chips as the answer. Restored Patrol assessment sessions must use the + `patrol-assessment` target identity rather than inheriting retired dashboard + context; Patrol no longer opens live assessment handoffs. While such a + session is attached, the Assistant empty message state must also remain source-named and must not fall back to generic cluster/system starter prompts that compete with the attached briefing. - Structured Patrol run and Patrol mode save-failure handoffs may render bounded, - redacted diagnostic lines in the drawer when they are opened directly from - Patrol runtime/control surfaces, but the attached headline must remain - source-owned (`Patrol run attached`, `Patrol mode ... attached`) + Structured Patrol run handoffs may render bounded, redacted diagnostic lines + in the drawer when they are opened directly from Patrol run history, but the + attached headline must remain source-owned (`Patrol run attached`) and the drawer must still exclude raw provider payloads, commands, and Patrol-authored remediation steps. Platform tabs in that same `frontend-modern/src/App.tsx` shell may remain @@ -4509,13 +4508,14 @@ resolve canonical/source IDs and unique aliases before collection, reject Assistant explains the current Patrol record rather than only the original investigation narrative. The saved-session handoff envelope must also preserve first-class Patrol source identity when product callers provide - safe metadata. Patrol assessment handoffs remain `patrol_assessment` - whole-surface review sessions even when their bounded action references - name individual findings; the session list must not infer a - `patrol_finding` identity from those action references once metadata is - present. Patrol mode save-failure handoffs remain - `patrol_configuration_failure` sessions for compatibility and may expose only the safe - runtime-failure boolean needed for browser presentation. Run-specific + safe metadata. Saved Patrol assessment sessions (the browser no longer + creates them) remain `patrol_assessment` whole-surface review sessions even + when their bounded action references name individual findings; the session + list must not infer a `patrol_finding` identity from those action + references once metadata is present. Saved Patrol mode save-failure + sessions remain `patrol_configuration_failure` sessions for compatibility + and may expose only the safe runtime-failure boolean needed for browser + presentation. Run-specific fields stay reserved for `patrol_run` handoffs, while hidden model context, command payloads, preflight output, and action results remain backend-owned. The finding briefing may surface the primary @@ -5505,17 +5505,11 @@ factor by the ratio of errored runs to relevant runs in the scoring window. Above 50% of recent runs erroring is a `-30` impact and is described as "Most recent Patrol runs encountered errors"; above 25% is `-20`; otherwise the original `-10` light-tier description applies. -This prevents the score chip from showing grade A while the same -assessment surface warns the operator that coverage is incomplete or -recent runs failed, which previously happened whenever one successful -manual run sat among many failed startup runs. -Downstream Assistant handoffs must treat that coverage factor as a -secondary caveat when Patrol also carries active findings, pending -approvals, or governed action references. The coverage-gap explanation -and scoped-activity prompt are primary Assistant framing only for -coverage-only assessments; active Patrol findings keep the prompt, -briefing action label, and safety note focused on finding priority, -affected resources, evidence, and the governed next step. +This keeps the grade from reading A when most recent runs errored, which +previously happened whenever one successful manual run sat among many failed +startup runs. A light error share still deducts only 10 points, and three +trailing successful full runs drop the penalty entirely. The Patrol page no longer renders the score; it reads the overall +health only to qualify the findings empty state. Absence-based auto-resolve paths in `internal/ai/patrol_ai.go` are all gated on the category whitelist exposed by diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e16912d9dc..4be492e8c1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2014,8 +2014,10 @@ payload shape change when the portal presents compact client rows. descriptions. That safe projection owns Patrol handoff identity as data, not browser inference: `kind` may identify `patrol_assessment`, `patrol_configuration_failure`, `patrol_run`, `patrol_finding`, or generic - `scoped_context`; assessment and configuration-failure sessions must not be - collapsed into one finding because a safe action reference contains a + `scoped_context`; the frontend no longer produces `patrol_assessment` or + `patrol_configuration_failure`, so those kinds come from sessions saved by + earlier builds, and assessment and configuration-failure sessions must not + be collapsed into one finding because a safe action reference contains a finding ID. `patrol_configuration_failure` may carry only the runtime-failure boolean needed for drawer/session presentation, and run-specific fields remain reserved for `patrol_run`. Chat requests that @@ -3197,7 +3199,7 @@ a new API state machine, queue contract, or verification-accounting field. what Patrol may do under the selected control level, and what to run or review next, not activation-loop proof, queue internals, or verification accounting - and the Patrol control dialog/cross-surface Assistant handoff copy, so API + and the Patrol control dialog plus restored Patrol mode Assistant session copy, so API compatibility identifiers such as `patrol_configuration_failure` may remain stable while user-facing and model-facing labels describe setting, saving, and reviewing Patrol control rather than a generic configuration/apply flow @@ -3307,36 +3309,8 @@ a new API state machine, queue contract, or verification-accounting field. and the Patrol page refresh lifecycle in `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, so slow or stalled secondary reads from that shared dashboard-load bundle may continue resolving in the background while the operator-facing Patrol refresh control remains generation-aware, timeout-bounded, and reusable once Patrol findings and status are already visible and the Patrol header support drawer in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, so API-owned Patrol status and trigger facts can feed a secondary Schedule & model surface without turning provider model, schedule, trigger tuning, or background-only runtime-policy pauses into the primary Patrol control decision and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines - and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so any Patrol recent-change, learned-correlation, or policy-coverage summary text is derived from the canonical AI payload in that one place instead of as hook-local count and pluralization logic; Patrol renders no such summary today + and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so if Patrol recent-change, learned-correlation, or policy-coverage summary text returns, it is derived from the canonical AI payload in that one place instead of as hook-local count and pluralization logic; Patrol renders no such summary today and the model carries no such derivation and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect - 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, active-finding, and - 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 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 context, briefing action labels, and safety notes must - 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: model-only context and visible briefing copy must explain what - scoped activity did and did not prove, keep latest-run and supporting-context - 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 drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, @@ -3354,7 +3328,7 @@ a new API state machine, queue contract, or verification-accounting field. and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' optional `buildResourceHref` input, so a surface that needs resource-filter links passes them into the shared card instead of rebuilding infrastructure URLs inline; the cards carry no link default since the cross-resource drilldown links were retired, and render resource labels as plain text - and the Patrol runtime-remediation destination shared with the AI settings endpoint, so summary actions, run-history runtime-failure actions, and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy + and the Patrol runtime-remediation destination shared with the AI settings endpoint, so run-history runtime-failure actions and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol` and the Patrol mode selector boundary, so the default header and the @@ -3482,8 +3456,9 @@ a new API state machine, queue contract, or verification-accounting field. 15. Keep Patrol status transport semantics explicit in that same AI handler layer: the Patrol status endpoint must carry machine-readable runtime availability such as blocked, running, disabled, active, or unavailable rather than asking frontend consumers to infer operator state from stale summaries or run history. 16. Keep legacy Patrol quickstart transport semantics retired from the public v6 GA contract: ordinary AI settings and Patrol status payloads must not expose quickstart credit/status fields, and any stale hosted-model blocked copy that survives from compatibility state must normalize back to provider/local-model setup rather than presenting credit badges or acquisition prompts. 17. Keep Patrol intelligence summary transport semantics single-voiced: the canonical overall-health payload and Patrol run-history payload together must support one primary assessment plus one explicit verification explanation, and frontend consumers must not need to derive a second compact assessment or verification verdict row from the same payloads beneath the primary assessment strip. - That same transport split now supports the visible Patrol assessment and - action metadata without adding another API field: the frontend summary + That same transport split supports Patrol assessment and action metadata + for any consumer without adding another API field (the Patrol page renders + no assessment strip today): the frontend summary contract derives compact state from existing overall-health, run-history, active finding, runtime, and pending-approval facts, so the API remains the source of facts while the configured LLM owns next-step reasoning. Those @@ -3495,7 +3470,7 @@ a new API state machine, queue contract, or verification-accounting field. compact operator summary, but it must not imply a hero, card, duplicate verdict layout, or expanded assessment panel. Normal Patrol page consumers should keep explanatory assessment, verification, and activity context in - the owning Findings and Runs surfaces rather than re-expanding the primary + the owning Findings and Runs surfaces rather than reintroducing an expanded assessment strip. 18. Keep Pulse Mobile relay credential minting and permission ownership on backend ownership: `internal/api/router_routes_auth_security.go`, `internal/api/security_tokens.go`, `internal/api/auth.go`, `internal/api/relay_mobile_capability.go`, `internal/api/router_routes_ai_relay.go`, and `frontend-modern/src/api/security.ts` may expose the canonical mobile runtime token creator and governed route gates, but browser callers must only consume that route and must not define the mobile runtime scope, compatibility gate list, route inventory, or token-purpose metadata locally. 19. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens. @@ -4323,7 +4298,7 @@ the authoritative analysis outcome. entitlements. 11. Treat Patrol summary supporting metrics as readouts, not reinterpretations: when frontend consumers derive cards such as active findings, criticals, warnings, or fixes from the canonical payloads, those cards must stay numeric and must not synthesize new assessment labels like `Issues detected` or verification labels like `Partial verification` beneath the primary summary contract 12. Treat active Patrol runtime transport as compatible with factual activity surfaces: when the runtime is currently running, frontend consumers may surface in-progress activity context, but they must not replace the activity strip with a second assessment verdict derived from runtime state alone -13. Treat Patrol recency as a singular transport-driven fact: once header metadata, verification copy, or the findings footer already present the governed Patrol timing context, frontend summary consumers must not derive an extra timing pill from the same payloads inside the primary summary card +13. Treat Patrol recency as a singular transport-driven fact: once header metadata, verification copy, or the findings footer already present the governed Patrol timing context, frontend summary consumers must not derive an extra timing pill from the same payloads inside any primary summary card 14. Treat Patrol findings counts as a singular supporting surface as well: when the summary shell already exposes count cards for active findings, warnings, criticals, and fixes, the primary assessment card must not repeat those same payload-derived counts as secondary badges 15. Treat Patrol schedule and recency as header-owned metadata on the main Patrol page: findings empty-state consumers should not receive or restate `next_patrol_at`, `last_patrol_at`, `last_activity_at`, or interval timing once those transport fields are already presented by the primary header and verification shell 16. Keep recovery payload filters canonical across `/api/recovery/rollups`, `/api/recovery/points`, `/api/recovery/series`, and `/api/recovery/facets`: when `internal/api/recovery_handlers.go` adds a governed recovery filter or display field such as provider-neutral `itemType`, the same normalized transport must land across all four endpoints and the contract tests must pin both outbound payload shape and accepted query aliases in the same slice @@ -7168,12 +7143,13 @@ boundary: unless the operator explicitly asks for discovery execution, live verification, or a read attempt, the runtime withholds tools and returns the attached context or an explicit missing-fact answer rather than treating the question as permission to call discovery/read tools. 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 persist them as user-authored message text, and the frontend +briefings, including restored assessment sessions, 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 attention, or Patrol finding context +(Patrol run-history handoffs send only safe run metadata: run ID, type, +status, and a runtime-failure flag; the backend rebuilds their context), but the backend must not persist them as user-authored message text, and the frontend must not turn their evidence or proposed remedies into user-authored message text. Ordinary attachment is context only. A labelled Explain action may submit the shared user-selected explanation request over the existing chat @@ -8692,7 +8668,7 @@ handoff helpers may consume current pending approval list payloads only as safe metadata for that visible briefing and any structured `handoff_actions`: approval ID, status, risk, request/expiry timestamps, target label, requester identity, action ID, approval policy, -plan expiry, and dry-run summary are allowed. Assessment-level visible +plan expiry, and dry-run summary are allowed. Finding-level visible briefings may reuse that same safe metadata for factual action labels and safety notes, while approval command text remains inside the governed approval/remediation surface. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a71ad69c35..b848351479 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1915,10 +1915,10 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`, 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. - Live Patrol assessment drawer opens must use that same + Restored Patrol assessment sessions use that same `patrol-assessment`/`pulse-patrol-assessment` target identity rather than a - retired dashboard target, so first-open and restored-session chrome remain - source-named. + retired dashboard target, so restored-session chrome remains source-named; + Patrol no longer opens live assessment handoffs. The shared `frontend-modern/src/components/shared/AIModelPicker.tsx` primitive must keep model route presentation delegated to the AI runtime label helpers. Pulse-owned local Assistant routes such as @@ -2140,17 +2140,17 @@ render it only when more than one vCenter is connected: the page passes cell drop together, and the inline detail row's column span follows. A table must not hide a column by filtered rows, because a search that narrows to one vCenter would otherwise make the column vanish. -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, +Patrol renders no primary assessment strip today. If one returns it 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. +Patrol-to-Assistant handoffs must carry only bounded evidence, resource references, and factual governed approval/action metadata as model-only context. Feature-owned Assistant handoffs may provide source context and safe metadata, but the shared drawer boundary must not turn those handoffs into frontend-authored prompts, tool routes, or remediation plans; the configured model owns tool choice and diagnostic reasoning after the request reaches the AI runtime. -The compact Patrol assessment strip may include factual recent activity mix and +Such a compact Patrol assessment strip may include factual recent activity mix and trigger-mode labels when those values are derived from the Patrol run-history and status payloads. Those labels are summary context inside the same strip, not a replacement status card, CTA band, or page-local nested card. @@ -3163,15 +3163,16 @@ Agent`), with the plain-language source phrase available through accessible that model instead of visually falling back to the default selection. Successful provider-model saves that return a not-ready Patrol readiness snapshot must use that same inline surface with `needs attention` - wording, while Assistant receives a saved configuration issue rather than a - failed-save handoff. When governed fixes are locked, the same Patrol state + wording, and any Assistant handoff from that state describes a saved + configuration issue rather than a failed save. When governed fixes are locked, the same Patrol state owner must clear stale full-mode unlock state before persisting the monitor-only autonomy payload, so disabled paid controls cannot leak stale permission into a save. If that inline state opens Assistant, the Patrol feature must hand off a source-named, model-only briefing and close the popover so the shared - Assistant drawer is not visually hidden behind feature chrome. When a Patrol - assessment handoff is attached, the shared Assistant drawer empty state must + Assistant drawer is not visually hidden behind feature chrome. When a + restored Patrol assessment session is attached, the shared Assistant drawer + empty state must stay aligned with that source-named briefing and must not render generic cluster/system starter prompts below the Patrol-owned context. The Patrol feature shell must also consume the Patrol-owned findings source for its @@ -3726,30 +3727,25 @@ production table, router and styles; it does not qualify full-app scrolling. `Rollback not specified` lines into the model-only Patrol finding prompt context so the operator-visible gap is surfaced to Assistant rather than hidden, and shared chat primitives stay free of that - placeholder logic. Patrol assessment-level handoffs must use - that same feature helper to attach bounded model-only assessment, - verification, latest-run, supporting-context evidence, active-finding, and - resource reference context while forcing request-local approval-required - mode. Patrol run-history handoffs must also use that feature helper rather + placeholder logic. Patrol run-history handoffs must also use that feature helper rather than a row-local Assistant prompt, so the shared drawer receives only a - generic visible briefing plus bounded model-only run context, scoped - resource references, runtime failure summary/detail, and - `autonomousMode:false` while the Patrol feature remains the source of run - copy and retry/configuration guidance. Active-finding entries in that - assessment handoff may add live pending - approval posture only as safe structured metadata: approval ID, pending - status, risk, target, requested/expiry timestamps, action plan identity, - requester identity, approval policy, plan expiry, dry-run posture, and - command count. Those entries may be passed through shared chat transport as - `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 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 + generic visible briefing, safe run identity metadata, and + `autonomousMode:false`; the backend rebuilds the bounded model-only run + context, scoped resource references, and runtime failure detail from the + run ID, while the Patrol feature remains the source of run copy and + retry/configuration guidance. Finding handoff action references + may add live pending approval posture only as safe structured metadata: + approval ID, pending status, risk, target, requested/expiry timestamps, + action plan identity, requester identity, approval policy, plan expiry, and + dry-run posture. Those references may be passed through shared chat + transport as `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 and safety notes for finding-level handoffs, but it must not produce + Patrol-authored suggested prompt chips, recommendation titles, + recommendation reasons, or route-owned next-step actions. Patrol finding + prompts, action labels, and safety notes must describe findings, pending + approvals, and governed action references 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, and one @@ -4494,23 +4490,21 @@ than carrying an inline copy, so hover and inline never drift apart on what Patrol actually owns: watching infrastructure, detecting issues, recording findings, and escalating into governed investigation/action only when the selected Patrol mode allows it. -The same `PatrolIntelligenceHeader.tsx` shell also renders a compact -trust-at-a-glance summary directly under the page title (a -render-only consumer of `state.patrolStatus()?.trust`), gated on at -least one non-zero trust signal so fresh installs render no header -strip. The detailed breakdown stays in -`PatrolIntelligenceWorkspace.tsx` for the canonical view; the header -line is the entry-point summary so operators see active, regressed, -and verified-fix counts before scrolling into the workspace tabs. +The same `PatrolIntelligenceHeader.tsx` shell renders no +trust-at-a-glance summary under the page title (Patrol's contract forbids a +second header trust line), and no page renders a trust counter row: +`state.patrolStatus()?.trust` only feeds the state hook's derived counts +(historical regressions and prior Patrol work). The recency line beside the header actions also renders coverage alongside time when the canonical `getPatrolRecencyPresentation` helper returns `resourcesCheckedLabel` from the latest completed run. Render code must gate on `` (truthy) so zero-coverage runs do not surface a misleading coverage phrase, failed or scoped runs use neutral checked wording, and only successful full patrols read -as verified. The primary Patrol assessment shell must pass the same run-history -facts into `getPatrolAssessmentPresentation` so assessment coverage caveats do -not contradict the header's verified full-run coverage state. +as verified. Any Patrol assessment shell must pass the same run-history facts +into `getPatrolAssessmentPresentation` so assessment coverage caveats do not +contradict the header's verified full-run coverage state; the page renders no +assessment shell today. The same header row may surface `Trigger status` when `getPatrolTriggerStatusSummary` returns a runtime-relevant value from the Patrol status payload. That text is page-owned operational metadata inside the @@ -4907,11 +4901,12 @@ or not fully verified. The same empty-state helper must consume Patrol trust-history evidence so a historical regression reads as history review context, not as a current issue and not as a healthy all-clear. -The same hierarchy also applies inside the Patrol summary shell: once the -primary assessment strip states Patrol's current risk and verification basis, +The Patrol page renders no summary shell today. If one returns, the same +hierarchy applies inside it: once its primary assessment strip states Patrol's +current risk and verification basis, supporting metrics under that strip must stay metric-oriented and must not repeat assessment or verification labels as a second compact verdict row. -The collapsed Patrol assessment strip itself must remain a compact readout +Its collapsed assessment strip must remain a compact readout rather than a headline-plus-paragraph block; explanatory assessment and recommendation copy belongs in the owning Findings, Runs, or Assistant chat surfaces rather than a normal-path summary details expansion. @@ -6779,12 +6774,9 @@ feature shell, reads its active workspace view from the route through `patrolWorkspaceRouting.ts` rather than holding it in component state, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns the runtime state machine, `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owns the pure Patrol-to-Assistant handoff and operator briefing derivation, -including the rule that active findings, pending -approvals, and governed action references outrank secondary coverage caveats -when building the Assistant prompt, action label, and safety note, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owns canonical AI summary normalization at the shared store boundary, and the Patrol-owned -header/banner/summary/workspace section files under +header, banner, attention workbench, workspace, and panel section files under `frontend-modern/src/features/patrol/` own the heavy render surfaces. Shared shell governance should reinforce that pattern instead of letting feature render surfaces re-accumulate API and timer orchestration inline. @@ -6796,7 +6788,10 @@ That same rule also applies to compact Patrol summary fragments inside the feature surface: count-only strips or metric cards must not emit `No issues found` or other reassuring copy when the owning overall-health summary is degraded or not fully verified. -That same summary shell should also surface verification scope from the +Patrol renders no summary shell today; the header recency line shows the +latest check or activity time, how many resources that check covered, and the +next scheduled check. +If a summary shell returns, it should also surface verification scope from the owning run-history contract. Operators should be able to see, inside the same summary surface, whether Patrol recently completed a full verification pass or whether recent activity was limited to scoped/erroring patrol runs. @@ -7210,9 +7205,9 @@ wording. Resource-change readers follow the same split: `getResourceChangePresentation` and `formatResourceChangeHeadline` in `utils/resourceChangePresentation.ts` own the `Alert moved` label, its neutral-blue tone and the summary headline -for an `alert_resolved` change with `alert_resolution` metadata, and the -Patrol assessment handoff consumes that headline instead of composing its own -kind prefix. +for an `alert_resolved` change with `alert_resolution` metadata, and +`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` +consumes that headline instead of composing its own kind prefix. `frontend-modern/src/features/alerts/useAlertHistoryState.ts` re-exposes the `getResource` resolver it is already given, and `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx` reads it diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 5cb104ab00..79374829db 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1085,10 +1085,11 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or present. The panel must not invent, smooth, or client-side infer a forecast; a missing forecast simply means no urgency line renders. The Patrol page must not render a standalone Trust strip above the - Findings/Runs tab bar or a parallel header trust line. The primary - Patrol assessment readout is the default owner for current operator - state, and may include high-signal trust facts such as - `state.patrolStatus()?.trust.regressed_at_least_once` (the + Findings/Runs tab bar or a parallel header trust line. Patrol renders no + primary assessment readout today; the header control row and the + attention workbench carry current operator state. If a readout returns, + it owns current operator state and may include high-signal trust facts + such as `state.patrolStatus()?.trust.regressed_at_least_once` (the FindingsTrustSummary block on the patrol-status response) only as compact status text. New trust categories must extend the `FindingsTrustSummary` contract first rather than inventing @@ -1153,45 +1154,18 @@ fix`, or `Explain` based on current finding state), while secondary than dropping it; runtime-failure findings produced by `internal/ai/patrol_runtime_failure.go` are the first source to depend on that path reaching the Patrol page surface. - The primary Patrol assessment summary may open Assistant as a whole-surface - review handoff, but that handoff must also flow through - `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`. - The summary action may pass the current assessment title, health score, - 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 - 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. - Active-finding - summaries may include live pending approval posture only as safe metadata: - approval ID, pending status, risk, target, requested/expiry timestamps, - action plan identity, approval policy, plan expiry, dry-run posture, and - command count. They must force request-local approval-required mode, keep raw - 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 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 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 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 no longer opens a whole-surface assessment handoff: the summary card + that offered it and its builder in + `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` are + gone. Assistant sessions saved from that handoff still restore through the + shared drawer as `handoff_metadata.kind=patrol_assessment` with the + `patrol-assessment` target and `targetId=pulse-patrol-assessment`, never as + generic scoped context or as a single-finding session because one bounded + action reference 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. 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 @@ -1209,8 +1183,11 @@ fix`, or `Explain` based on current finding state), while secondary frame Assistant as explanation and next-step review rather than execution or automatic retry authority. 9. Keep the normal Patrol status summary plain and operator-first rather - than a hero-style or decorative card surface. The default collapsed state is - a compact readout, not a headline block: show the Patrol status label, + than a hero-style or decorative card surface. The default page renders no + status summary card or assessment strip today: the header control row + carries Patrol state, mode, trigger status, and recency. If a collapsed + status readout returns, it is a compact readout, not a headline block: show + the Patrol status label, current operator state, concise high-signal trust posture 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 @@ -1399,12 +1376,11 @@ condition went, and that it is not a recovery) sits directly under the summary rather than only inside Evidence and history (`getAttentionHandoverClose` in `patrolHomePresentation.ts`). The timeline names that cause `Ownership transferred`, never `Recovery evidence`. -The assessment handoff tells the same close the same way: a recent -`alert_resolved` change carrying `alert_resolution` metadata enters the -handoff context and briefing evidence as the alert engine's summary through -the shared `formatResourceChangeHeadline`, never as `Alert resolved: ...` -(`patrolInvestigationContextModel.ts`, pinned by -`patrolInvestigationContextModel.test.ts`). +Any Patrol Assistant handoff that attaches recent changes must tell the same +close the same way: an `alert_resolved` change carrying `alert_resolution` +metadata reads as the alert engine's summary through the shared +`formatResourceChangeHeadline`, never as `Alert resolved: ...`. No Patrol +handoff attaches recent changes today. The active Patrol queue now uses compact severity-accented rows for prioritisation and a single focused review panel for the selected issue. The @@ -1543,27 +1519,29 @@ once the shared catalog supplies it, not fall back visually to the default model or an unrelated OpenRouter entry because the popover mounted after the catalog request completed. The Patrol page now also treats Patrol runtime availability as a first-class -render contract: the header chip, primary summary card, and status bar must -all route through the shared `frontend-modern/src/utils/patrolRuntimePresentation.ts` +render contract: any Patrol surface that presents runtime availability must +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 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, 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. +Patrol renders no primary summary strip today. If one returns it stays +descriptive only: it surfaces the current Patrol assessment label and compact +counts, while action choices remain in the workspace, header controls, and the +LLM-driven Assistant chat. +`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` +packages the Patrol finding, attention-item, and run handoffs, carrying only +bounded model-only context, resource references, and safe structured +approval/action references, and 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 `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` with a -`[Patrol Run Context]` block, scoped resource references, runtime failure -summary/detail when present, bounded outcome and coverage facts, and a visible -`Patrol run attached` drawer briefing. The row must not create a second +visible `Patrol run attached` drawer briefing (bounded outcome and coverage +facts plus a redacted runtime failure summary when present) and safe run +identity metadata; the backend (`internal/ai/patrol_assistant_handoff.go`) +builds the model-only `[Patrol Run Context]` block and scoped resource +references from the run ID. The row must not create a second prompt-only Assistant shortcut or imply that Assistant can retry Patrol, change provider configuration, or execute remediation from run-history context. That active-runtime label must stay operational rather than verdict-like: the @@ -1583,21 +1561,22 @@ viewport, avoid duplicating the inline Patrol control policy, expose provider/model, schedule, trigger, and user-level model checks directly, and surface the backend's concrete license/validation reason when a settings change is rejected instead of replacing it with a generic `Failed to save advanced -settings` toast. That inline failure may -handoff to Assistant only as model-only explanation context: raw command, -script, credential, and provider-detail payloads stay redacted, Assistant opens -with `autonomousMode:false`, and the Patrol control panel closes so the operator -is not left behind an overlapping popover. Patrol-control save-failure handoffs -must keep the compatible `handoff_metadata.kind=patrol_configuration_failure` plus only -the runtime-failure boolean needed for drawer/session presentation, so the -saved session restores as a Patrol control issue without carrying raw -provider, credential, command, or retry payloads into the browser. +settings` toast. That inline failure no longer +opens Assistant. If it does again, the handoff is model-only explanation +context: raw command, script, credential, and provider-detail payloads stay +redacted, Assistant opens with `autonomousMode:false`, and the Patrol control +panel closes so the operator is not left behind an overlapping popover. +Patrol-control save-failure sessions saved by earlier builds keep the +compatible `handoff_metadata.kind=patrol_configuration_failure` plus only the +runtime-failure boolean needed for drawer/session presentation, so they restore +as a Patrol control issue without carrying raw provider, credential, command, +or retry payloads into the browser. Successful provider-model saves that return `patrol_readiness.status=not_ready` are still Patrol control issues, not silent successes: the Patrol popover must keep the saved provider/model visible, render a `Patrol control needs attention` inline state with the returned -readiness cause and summary, and hand off to Assistant as a model-only Patrol -control issue rather than as a save failure. +readiness cause and summary; any Assistant handoff from that state must +describe a Patrol control issue rather than a save failure. A separate browser proof in `tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts` keeps the authenticated launch workbench monitor-first. When the runtime has monitored @@ -1618,11 +1597,12 @@ control state owner must also clamp stale investigation/remediation autonomy back to findings-only `monitor` and clear stale full-mode unlock state before persisting Patrol control when the safe-remediation entitlement is not effective, so an expired or downgraded plan cannot turn a recoverable control -review into a Pro-only save failure. The same inline error -surface must render any Patrol readiness -provider, model, and summary carried by the failure object, and keep the -provider-settings action immediately available before the operator chooses to -open the Assistant handoff. +review into a Pro-only save failure. Patrol renders no inline error +surface for these failures today: switching Patrol on or off while it is not +ready raises a fixed warning notification, a failed Patrol mode save raises an error +notification, and neither offers an Assistant handoff. A provider or model save +in Settings > Provider & Models reports the readiness summary, provider and +model in its own save message. The Patrol mode selector in that header and configuration dialog must compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` instead of rebuilding a local active-button group. The wide default Patrol @@ -1719,12 +1699,6 @@ consumers must not pair an `Issues detected` headline with a raw coverage-only `overall_health.prediction` sentence from a separate source; when active findings and incomplete verification are both true, the assessment should describe both in one canonical message. -The same precedence applies to Patrol-to-Assistant assessment handoffs. An -active finding, pending approval, or governed action reference must remain the -primary Assistant prompt and briefing posture even when the attached model -context carries a secondary coverage caveat. Coverage-incomplete language such -as explaining scoped activity or a full-run gap is the primary handoff framing -only for coverage-only assessments with no active findings. That same assessment contract must also distinguish Patrol-owned runtime findings from infrastructure findings. When the only active Patrol findings are synthetic Patrol service/runtime conditions such as the `ai-service` @@ -1747,18 +1721,18 @@ inviting generic Assistant chat. When that recommendation has an immediate governed operator path, the same helper must also declare the bounded action kind. The summary card may map those action kinds only to existing Patrol controls: run a full Patrol, -review approval-scoped findings, review active findings, open Patrol provider -settings, or open the Patrol-owned Assistant assessment handoff. It must not +review approval-scoped findings, review active findings, or open Patrol +provider settings. It must not invent page-local remediation, approval, or execution authority from recommendation text. -That same runtime-owned assessment must expose the fix path directly. When the -primary Patrol issue is a Patrol runtime/provider problem rather than an -infrastructure finding, the summary card should offer a direct `Open Patrol +If a summary card returns, that same runtime-owned assessment must expose the +fix path directly. When the primary Patrol issue is a Patrol runtime/provider +problem rather than an infrastructure finding, the summary card should offer a direct `Open Patrol provider settings` action instead of making the operator dig through the findings list to discover where to correct provider configuration. That same runtime-versus-infrastructure distinction should route through the shared finding-presentation helper instead of being re-inferred separately by -the summary card and the findings list. The active finding row should surface +any summary card and the findings list. The active finding row should surface the same Patrol runtime classification with a runtime-qualified severity badge such as `Runtime issue` or `Runtime critical`, rather than pairing a generic infrastructure severity chip like `warning` with a second Patrol-runtime @@ -1798,9 +1772,9 @@ rather than repeating the product prefix or legacy `Insufficient API credits` wording once the surrounding UI already makes the Patrol context explicit. That same finding presentation contract should own the primary remediation path for Patrol-owned runtime findings as well. Expanded runtime-finding rows should -lead with the same direct `Open Provider & Models` action that the top -assessment uses, and that action must use the canonical Pulse Intelligence -route `/settings/pulse-intelligence/provider` rather than the legacy +lead with the shared `getPatrolProviderSettingsAction()` action (`Check Patrol +model`), and that action must use the canonical Pulse Intelligence +route `/settings/pulse-intelligence/patrol` rather than the legacy `/settings/system-ai` compatibility alias. Runtime/setup findings in the default current-issues flow must stay primary-action-only: they should not add a visible `Open in Assistant` secondary button or a generic `Manage` menu @@ -1846,14 +1820,14 @@ work or fix-verification checks without claiming a new full verification pass. That same recency contract also applies to the header metadata row. The top header must not revert to a generic `Last:` timestamp when the rest of Patrol is explicitly distinguishing activity from full verification recency. -That summary surface must also avoid reintroducing a second compact assessment -or verification layer beneath the primary card. Supporting metric strips +If Patrol renders a summary surface again, it must also avoid reintroducing a +second compact assessment or verification layer beneath the primary card. Supporting metric strips belong to counts and outcomes such as active findings, critical findings, warnings, and fixes; they must not repeat Patrol assessment labels or verification labels in a second row that competes with the primary governed assessment and verification copy above. -The same supporting-chip rule applies to timing: the primary summary card may -show health and active-finding support, but it should not add another recency +The same supporting-chip rule applies to timing: such a primary summary card +may show health and active-finding support, but it should not add another recency pill once header metadata, verification, and findings footer already carry the governed activity/verification timestamps. That same Patrol-owned run-history surface must keep platform-backed system @@ -1865,8 +1839,9 @@ That run-history distinction must not reopen a parallel raw `truenas` resource-type contract in Patrol findings, scoped-run filters, or alert-backed resource state. Those payloads stay canonical `agent` plus platform context, while the separate `TrueNAS` count remains a run-history coverage detail. -That summary-card/metrics-strip split also applies to findings counts. The -primary assessment card may keep health as supporting context, but active +If Patrol renders a summary card again, the same summary-card/metrics-strip +split also applies to findings counts: the primary assessment card may keep +health as supporting context, but active findings, warning counts, and critical counts belong to the supporting metric strip rather than being repeated as duplicate badges inside the primary card. That same summary surface must also explain what Patrol actually checked @@ -1924,8 +1899,8 @@ header settings surface should expose alert-triggered and anomaly-triggered scoped patrols as separate controls, with the legacy aggregate event-trigger toggle treated as compatibility-only transport rather than the primary product model. -The findings empty state must also stay subordinate to the Patrol header and -assessment shell rather than mirroring their timing metadata. In the primary +The findings empty state must also stay subordinate to the Patrol header +rather than mirroring its timing metadata. In the primary Patrol page, `frontend-modern/src/components/AI/FindingsPanel.tsx` should explain the absence of active findings without repeating `Last activity`, `Next run`, or interval schedule details that already belong to the header and @@ -2345,7 +2320,8 @@ from the canonical helper rather than maintaining a parallel copy, so hover and inline never tell different stories about what Patrol does. The Patrol page header must not add a second trust-at-a-glance line under the page title. Current active/regressed/finding posture belongs to the -primary assessment readout and the finding rows, so the header remains +workspace below the tabs and its finding rows (Patrol renders no primary +assessment readout today), so the header remains focused on product title, recency, and route controls instead of repeating the same trust counters above the workspace tabs. The same page-header recency line must also surface the coverage diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 3bd46e4a70..1524faa42c 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2021,7 +2021,7 @@ Assistant handoff context may hydrate those normalized action-audit facts for review, but that read is still model-only context: it must remain org-scoped, must not expose raw command text or raw execution output, and must not grant approval or execution authority. -Scoped Assistant `handoff_actions` from Patrol assessment handoffs may carry +Scoped Assistant `handoff_actions` from Patrol finding and attention handoffs may carry only safe approval/action metadata for model-only refresh, including approval 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. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 53b6bd3c34..cc116bc94d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1961,7 +1961,7 @@ recovery scope, or a storage/recovery-owned secret source. details attached to that briefing are review context only and must not become backup freshness evidence, restore eligibility, storage health truth, or recovery execution authority. - Alert, incident, and Patrol assessment Assistant handoffs that send bounded + Alert, incident, and Patrol attention Assistant handoffs that send bounded model-only `handoff_context`, `handoff_resources`, or `handoff_actions` through `/api/ai/chat` without a `finding_id` stay on that same adjacent AI/runtime boundary. Storage and recovery surfaces may consume the diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3b9f065d08..994ea1c9bf 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4270,8 +4270,7 @@ the `ResourceChangeSummary` badge reads `Alert moved` in the neutral blue of the alert history's "moved to agent" badge (`Alert closed` for a reason code the build does not know), and `formatResourceChangeHeadline` uses the summary as the whole headline instead -of prefixing `Alert resolved:`; Patrol's assessment context reuses that -headline. A node's own drawer does not list the move yet: node and guest +of prefixing `Alert resolved:`. A node's own drawer does not list the move yet: node and guest alert changes are still recorded under their legacy alert resource IDs (`MonitorAdapter.RecordChange` maps only Docker IDs), and Proxmox platform rows open the drawer in the `table-row` presentation, which fetches no diff --git a/frontend-modern/browser-verification/6e6cbc66d62863d1.json b/frontend-modern/browser-verification/6e6cbc66d62863d1.json new file mode 100644 index 0000000000..f8e63edbb4 --- /dev/null +++ b/frontend-modern/browser-verification/6e6cbc66d62863d1.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "base_sha": "d67bef7519cd1206f4f5b61f9aa4a25bb5348cda", + "verified_at": "2026-10-07T14:18:14Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "537a76c9a3beda65a52b92c4a2298111cf747511f154a2d9fc983b4601a37336" + }, + "routes": [ + "/patrol", + "/patrol/activity" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Tree = PR 2674 head d67bef7519 plus this change; production vite build embedded in a go build binary, private DEMO_MODE mock stack on :7686 (demo/demo, PULSE_TELEMETRY=false, fresh data dir), driven by Playwright", + "Runtime-neutral proof: production builds of the parent tree and of this tree are byte-identical (all 187 dist files equal by shasum), so both removed builders and their helpers were already tree-shaken; check-bundle-size.mjs passes; the bundle holds neither 'Discuss Patrol assessment' nor 'Patrol mode save failure attached', and still holds the restore-path title 'Patrol mode save failure'", + "Assistant drawer after the Inbox decision's 'Explain with Assistant' shows the Pulse Patrol attention briefing ('critical \u00b7 open', the selected decision's resource, 'Approval required before any action.') from buildPatrolAttentionAssistantHandoff; the demo instance refuses the reply itself ('Demo mode enabled'), which is expected read-only behaviour", + "Assistant drawer after the selected run's 'Discuss' shows the 'Patrol run attached' briefing with the run id, timing, outcomes and the operator-controlled safety note from buildPatrolRunAssistantHandoff", + "No page errors at either viewport" + ], + "interactions": [ + "Loaded /patrol, opened the first row of 'Patrol attention items' and clicked 'Explain with Assistant', then closed the drawer", + "Loaded /patrol, switched to Activity, expanded 'Finding options and history', clicked History, opened the newest run (check details), returned to History and clicked the selected run's 'Discuss'", + "Repeated every step at 1440x900 and 390x844 (phone context with touch)" + ] +} diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md index f23b965ece..b63402daa4 100644 --- a/frontend-modern/public/docs/AI.md +++ b/frontend-modern/public/docs/AI.md @@ -709,11 +709,12 @@ Pulse includes a model-context layer that aggregates evidence from AI runtime su | **Knowledge Store** | Persists user annotations and model-safe context | | **Forecast Service** | Estimates capacity trajectories from historical samples | -### Health Scoring +### Check History -Historical Patrol checks and the legacy operational score remain available in -the collapsed supporting-context section. They are not the primary daily -monitoring answer and do not replace the canonical attention queue. +Historical Patrol checks remain available from **Activity > Finding options and +history**, under **History**. Patrol no longer shows the legacy operational +score. Check history is not the primary daily monitoring answer and does not +replace the canonical attention queue. --- diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov.test.ts index cc91534557..3520f4f5d4 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov.test.ts @@ -12,7 +12,6 @@ import { buildPatrolAssistantFindingHandoffInputFromUnifiedFinding, buildPatrolAssistantProposedFixBriefingInput, buildPatrolAssistantProposedFixBriefingInputFromApproval, - buildPatrolConfigurationFailureHandoff, buildPatrolInvestigationRecordPresentation, buildPatrolRunAssistantHandoff, } from '../patrolInvestigationContextModel'; @@ -464,72 +463,6 @@ describe('buildPatrolAssistantFindingHandoffInputFromUnifiedFinding (branch cove }); }); -describe('buildPatrolConfigurationFailureHandoff (branch coverage)', () => { - it('falls back to the default message and bare subject when message and code are empty', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ message: '' }); - expect(handoff.context.briefing?.subject).toBe('Patrol mode could not be saved.'); - expect(handoff.context.briefing?.title).toBe('Patrol mode save failure attached'); - expect(handoff.context.briefing?.statusLabel).toBeUndefined(); - expect(handoff.context.briefing?.detailLines).toEqual([]); - }); - - it('derives the cause from blockedCause when readiness is absent', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - blockedCause: 'provider_unavailable', - }); - - expect(handoff.context.briefing?.statusLabel).toBe('provider_unavailable'); - expect(handoff.context.briefing?.detailLines).toEqual(['Cause: Provider Unavailable']); - expect(handoff.context.context?.readinessCause).toBe('provider_unavailable'); - }); - - it('renders the full requested-settings line for an enabled full-mode configuration', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - autonomyLevel: 'approval', - fullModeUnlocked: true, - investigationBudget: 5, - investigationTimeoutSec: 30, - }); - - expect(handoff.context.briefing?.detailLines).toContain( - 'Requested settings: mode approval, automatic critical fixes enabled, budget 5, timeout 30s', - ); - }); - - it('surfaces provider and model when readiness carries only those fields', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - readiness: { provider: 'ollama', model: 'm1' }, - }); - - expect(handoff.context.briefing?.detailLines).toEqual(['Provider: ollama', 'Model: m1']); - expect(handoff.context.context?.provider).toBe('ollama'); - expect(handoff.context.context?.model).toBe('m1'); - }); - - it('withholds sensitive keys and command-bearing values while showing safe details', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - details: { - db_password: 'secret123', - normal_field: 'visible value', - cmd_value: 'operator ran sudo rm', - }, - }); - - expect(handoff.context.briefing?.evidence).toEqual([ - 'Db Password: sensitive or command detail withheld', - 'Normal Field: visible value', - 'Cmd Value: sensitive or command detail withheld', - ]); - expect(JSON.stringify(handoff)).not.toContain('secret123'); - expect(JSON.stringify(handoff)).not.toContain('sudo rm'); - expect(JSON.stringify(handoff)).toContain('visible value'); - }); -}); - describe('buildPatrolAssistantFindingBriefing (branch coverage)', () => { it('returns undefined when there are no finding facts, record, approval, or fix', () => { expect(buildPatrolAssistantFindingBriefing({ title: 'T', subject: 'S' })).toBeUndefined(); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov0723pm.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov0723pm.test.ts index 297adf1c3d..82dac3bb73 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov0723pm.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.branchcov0723pm.test.ts @@ -1,16 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { RemediationPlan } from '@/api/ai'; import type { InvestigationRecord } from '@/api/ai'; -import type { ResourceCorrelation } from '@/types/aiIntelligence'; -import type { ResourceChange } from '@/types/resource'; import type { PatrolRunRecord } from '@/api/patrol'; import { - buildPatrolAssessmentAssistantHandoff, buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingHandoff, buildPatrolAssistantFindingHandoffActions, - buildPatrolConfigurationFailureHandoff, buildPatrolInvestigationRecordPresentation, buildPatrolRemediationPlanAssistantBriefing, buildPatrolRemediationPlanAssistantModelContext, @@ -164,520 +160,6 @@ describe('buildPatrolRunAssistantHandoff (residual branches)', () => { }); }); -describe('buildPatrolConfigurationFailureHandoff (residual branches)', () => { - it('emits the Cause line using the identifier-formatted cause', () => { - // cause truthy arm of the Cause detail-line ternary. - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - readiness: { cause: 'model_unsupported_tools' }, - }); - - expect(handoff.context.briefing?.detailLines).toContain('Cause: Model Unsupported Tools'); - expect(handoff.context.context?.readinessCause).toBe('model_unsupported_tools'); - }); - - it('omits the Cause line entirely when no cause is available', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ message: 'm' }); - - expect(handoff.context.briefing?.detailLines).toEqual([]); - expect(handoff.context.briefing?.statusLabel).toBeUndefined(); - }); - - it('withholds a detail whose key or value normalizes to empty', () => { - // formatSafeConfigurationFailureDetail early-return when key or value is empty. - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - details: { - ' ': 'visible', - empty_value: ' ', - good: 'kept', - }, - }); - - expect(handoff.context.briefing?.evidence).toEqual(['Good: kept']); - }); - - it('passes a digits-only detail key through the identifier formatter unchanged', () => { - // formatIdentifierLabel('123') returns '123' - there is no [._-] to expand - // and no letter to title-case - so the label is the key verbatim WITHOUT - // the || fallback being taken. The genuine empty-formatter fallback arm is - // covered separately by the separator-only key case below. - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - details: { '123': 'value-here' }, - }); - - expect(handoff.context.briefing?.evidence).toEqual(['123: value-here']); - }); -}); - -describe('buildPatrolAssessmentAssistantHandoff (residual branches)', () => { - it('falls back to the default assessment title in both briefing and model context', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { description: 'only a description' }, - activeFindings: [], - }); - - expect(handoff.context.briefing?.subject).toBe('Pulse Patrol assessment'); - expect(handoff.context.handoffContext).toContain('Assessment: Pulse Patrol assessment'); - }); - - it('drops findings that carry no id, title, resourceId, or record id, and tolerates a null list', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - activeFindings: [ - { severity: 'critical' }, - { title: 'kept-by-title' }, - { resourceId: 'kept-by-resource' }, - { investigationRecord: { id: 'rec-kept' } as unknown as InvestigationRecord }, - ], - }); - - // activeFindingCount reflects the raw input length, but the severity-only - // finding (no id/title/resourceId/record id) is filtered out of the model - // context, so only three Finding lines render. - expect(handoff.context.context?.activeFindingCount).toBe(4); - expect(handoff.context.handoffContext).toContain('Finding 1: kept-by-title'); - expect(handoff.context.handoffContext).toContain('Finding 3: Patrol finding'); - expect(handoff.context.handoffContext).not.toContain('Finding 4:'); - }); - - it('counts active findings as zero and omits actions when activeFindings is null', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ activeFindings: null }); - - expect(handoff.context.context?.activeFindingCount).toBe(0); - expect(handoff.context.context?.pendingApprovalCount).toBe(0); - expect(handoff.context.handoffActions).toBeUndefined(); - }); - - it('reports omitted findings and an omitted singular recent change in the model context', () => { - const findings = Array.from({ length: 6 }, (_, i) => ({ - id: `f-${i + 1}`, - title: `Finding ${i + 1}`, - })); - const changes = Array.from({ length: 4 }, (_, i) => ({ - id: `c-${i + 1}`, - observedAt: '2026-01-01T00:00:00Z', - resourceId: `vm-${i + 1}`, - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'high', - reason: `reason ${i + 1}`, - })) as unknown as ResourceChange[]; - - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { recentChanges: changes }, - activeFindings: findings, - }); - - // 6 findings, cap 5 -> 1 omitted (singular wording). - expect(handoff.context.handoffContext).toContain( - '1 additional Patrol finding omitted from this bounded handoff summary.', - ); - // 4 recent changes, cap 3 -> 1 omitted (singular wording). - expect(handoff.context.handoffContext).toContain( - '1 additional recent change omitted from this bounded handoff summary.', - ); - }); - - it('renders a finding context line with no resource, no id, and no status parts', () => { - // Bare finding: resource resolves to nothing, finding.id empty, no status - // fields -> each ternary takes its undefined arm. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: [{ title: 'Bare finding' }], - }); - - expect(handoff.context.handoffContext).toContain('Bare finding on affected resource'); - // No "finding " segment, no status segment -> the line is exactly the - // title-on-resource form with no trailing parts. - const ctx = handoff.context.handoffContext!; - const line = ctx.split('\n').find((l) => l.startsWith('Finding 1:')); - expect(line).toBe('Finding 1: Bare finding on affected resource'); - }); - - it('surfaces record error, regression singular, and a divergent record approval id', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: [ - { - id: 'f-1', - title: 'High CPU', - regressionCount: 1, - pendingApproval: { id: 'pa-1', status: 'approved', riskLevel: 'high' }, - investigationRecord: { - id: 'rec-1', - finding_id: 'f-1', - subject: { resource_id: 'vm-1' }, - trigger: { detected_at: '2026-01-01T00:00:00Z' }, - status: 'completed', - error: 'investigation aborted', - approval_id: 'rec-approval', - } as unknown as InvestigationRecord, - }, - ], - }); - - const line = handoff.context - .handoffContext!.split('\n') - .find((l) => l.startsWith('Finding 1:'))!; - expect(line).toContain('regressed 1 time'); - expect(line).toContain('investigation error investigation aborted'); - expect(line).toContain('approval rec-approval'); - // Non-pending approval posture is rendered as inline approval parts: - // "approved approval" (status) and "high risk" (risk label). - expect(line).toContain('approved approval'); - expect(line).toContain('high risk'); - }); - - it('renders metric fallback labels, singularized custom and non-plural labels, and the no-signal summary', () => { - // primaryLabel absent -> default 'Active findings' label; fixedLabel - // 'Categories' with value 1 -> singular 'category' (ies rule). - const primaryAndCategories = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - metricState: { primaryValue: 1, fixedValue: 1, fixedLabel: 'Categories' }, - activeFindings: [], - }); - expect(primaryAndCategories.context.briefing?.statusLabel).toContain('1 active finding'); - expect(primaryAndCategories.context.briefing?.statusLabel).toContain('1 category'); - - // secondaryLabel absent (and distinct from the explicit primary label) -> - // default 'Warnings' label. - const secondaryFallback = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - metricState: { primaryLabel: 'Active findings', primaryValue: 1, secondaryValue: 1 }, - activeFindings: [], - }); - expect(secondaryFallback.context.briefing?.statusLabel).toContain('1 active finding'); - expect(secondaryFallback.context.briefing?.statusLabel).toContain('1 warning'); - - // Non-plural label ('Fixed') with value 1 -> stays 'fixed'. - const fixedOne = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - metricState: { fixedValue: 1 }, - activeFindings: [], - }); - expect(fixedOne.context.briefing?.statusLabel).toContain('1 fixed'); - - // No metric values and no findings -> default attention summary. - const empty = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: [], - }); - expect(empty.context.briefing?.statusLabel).toContain('No active Patrol findings'); - }); - - it('always emits a Health line, defaulting the chip label to "Health" when no signal is present', () => { - // formatAssessmentHealth can never be empty because the chip label itself - // defaults to 'Health'; with no grade or score the line is just that label. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain('\nHealth: Health'); - }); - - it('includes verification sub-facts only when their timestamps are present', () => { - const withFacts = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - verification: { - title: 'Checked', - description: 'desc', - lastFullRunAt: '2026-01-01T00:00:00Z', - activityMixLabel: 'mix', - }, - activeFindings: [], - }); - expect(withFacts.context.handoffContext).toContain('last full run 2026-01-01T00:00:00Z'); - expect(withFacts.context.handoffContext).toContain('recent activity mix mix'); - - const withoutFacts = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - verification: { title: 'Checked', description: 'desc' }, - activeFindings: [], - }); - expect(withoutFacts.context.handoffContext).not.toContain('last full run'); - expect(withoutFacts.context.handoffContext).not.toContain('recent activity mix'); - }); - - it('falls back to the Last Patrol label and returns undefined recency with no signal', () => { - const withTimestampOnly = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - recency: { timestamp: '2026-01-01T00:00:00Z' }, - activeFindings: [], - }); - expect(withTimestampOnly.context.handoffContext).toContain( - 'Last Patrol: Last Patrol 2026-01-01T00:00:00Z', - ); - - const withNothing = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - recency: null, - activeFindings: [], - }); - expect(withNothing.context.handoffContext).not.toContain('\nLast Patrol:'); - }); - - it('builds sparse correlation endpoints and their context/evidence lines', () => { - // Correlation with only a source_id (no names/types, no target id) -> - // endpoint name/type collapse to undefined; the empty target id means the - // target resource is never added. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - correlations: [ - { - source_id: 'src-1', - event_pattern: 'p', - occurrences: 1, - avg_delay: 1, - } as unknown as ResourceCorrelation, - ], - }, - activeFindings: [], - }); - - // Correlation context line renders the source id. - expect(handoff.context.handoffContext).toContain('Correlation 1: src-1'); - // Only the source resource is added, with no name or type (empty target id - // is dropped by addAssessmentHandoffResource). - expect(handoff.context.handoffResources).toEqual([{ id: 'src-1' }]); - }); - - it('renders correlation context sub-facts (pattern, last seen, description) and full evidence join', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - correlations: [ - { - source_id: 'src-1', - source_name: 'Source', - source_type: 'vm', - target_id: 'tgt-1', - target_name: 'Target', - target_type: 'host', - event_pattern: 'a -> b', - occurrences: 1, - avg_delay: 1, - confidence: 0.9, - last_seen: '2026-01-01T00:00:00Z', - description: 'correlated activity', - }, - ], - }, - activeFindings: [], - }); - - const line = handoff.context - .handoffContext!.split('\n') - .find((l) => l.startsWith('Correlation 1:'))!; - // Both endpoints resolve -> the " to " join is used. - expect(line).toContain('Source'); - expect(line).toContain(' to '); - expect(line).toContain('Target'); - expect(line).toContain('last seen 2026-01-01T00:00:00Z'); - expect(line).toContain('description correlated activity'); - const evidence = handoff.context.briefing?.evidence ?? []; - expect(evidence.some((e) => e.includes('Source') && e.includes(' to '))).toBe(true); - expect(evidence.some((e) => e.includes('pattern'))).toBe(true); - }); - - it('renders sparse recent-change evidence and context sub-facts', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-1', - observedAt: '2026-01-01T00:00:00Z', - occurredAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'config_update', - sourceType: 'user_action', - sourceAdapter: 'proxmox_adapter', - confidence: 'medium', - actor: 'operator-one', - reason: 'manual edit', - }, - ], - }, - activeFindings: [], - }); - - const line = handoff.context - .handoffContext!.split('\n') - .find((l) => l.startsWith('Recent Change 1:'))!; - expect(line).toContain('change c-1'); - expect(line).toContain('resource vm-1'); - expect(line).toContain('observed 2026-01-01T00:00:00Z'); - expect(line).toContain('occurred 2026-01-01T00:00:00Z'); - expect(line).toContain('source user action'); - expect(line).toContain('adapter Proxmox Adapter'); - expect(line).toContain('medium confidence'); - expect(line).toContain('actor operator-one'); - }); - - it('falls back to the change id then the bare resource token when no reason is present', () => { - const byId = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-id', - observedAt: '2026-01-01T00:00:00Z', - resourceId: '', - kind: 'activity', - sourceType: 'platform_event', - confidence: 'low', - }, - ], - }, - activeFindings: [], - }); - expect(byId.context.handoffContext).toContain('Recent Change 1: Activity: c-id'); - - const bare = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: '', - observedAt: '', - resourceId: '', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'low', - }, - ], - }, - activeFindings: [], - }); - expect(bare.context.handoffContext).toContain('Recent Change 1: Metric anomaly: resource'); - }); - - it('renders a state-transition summary for differing states and keeps command-bearing wording', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-trans', - observedAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'state_transition', - from: 'offline', - to: 'online', - sourceType: 'pulse_diff', - confidence: 'high', - }, - { - id: 'c-runbook', - observedAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'runbook_executed', - sourceType: 'agent_action', - confidence: 'high', - reason: 'ran the playbook', - }, - ], - }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain( - 'Recent Change 1: State transition: offline to online', - ); - expect(handoff.context.handoffContext).toContain( - 'Recent Change 2: Runbook executed: execution event recorded', - ); - }); - - it('formats the two-label related-resource list and a no-omission three-label list', () => { - const two = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-2', - observedAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'high', - reason: 'r', - relatedResources: ['alpha', 'beta'], - }, - ], - }, - activeFindings: [], - }); - expect(two.context.handoffContext).toContain('related resources alpha and beta'); - - const three = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-3', - observedAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'high', - reason: 'r', - relatedResources: ['alpha', 'beta', 'gamma'], - }, - ], - }, - activeFindings: [], - }); - expect(three.context.handoffContext).toContain('related resources alpha, beta, gamma'); - }); - - it('caps assessment handoff resources at eight across many distinct resources', () => { - const findings = Array.from({ length: 5 }, (_, i) => ({ - id: `f-${i + 1}`, - title: `F${i + 1}`, - resourceId: `finding-res-${i + 1}`, - })); - const changes = Array.from({ length: 3 }, (_, i) => ({ - id: `c-${i + 1}`, - observedAt: '2026-01-01T00:00:00Z', - resourceId: `change-res-${i + 1}`, - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'high', - reason: 'r', - relatedResources: [`rel-${i + 1}-a`, `rel-${i + 1}-b`], - })) as unknown as ResourceChange[]; - - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { recentChanges: changes }, - activeFindings: findings, - }); - - // MAX_ASSESSMENT_RESOURCES = 8; the ninth distinct resource is dropped. - expect(handoff.context.handoffResources).toHaveLength(8); - }); - - it('caps assessment handoff actions at four when more action-bearing findings exist', () => { - const findings = Array.from({ length: 6 }, (_, i) => ({ - id: `f-${i + 1}`, - title: `F${i + 1}`, - proposedFix: { description: `Restart ${i + 1}` }, - })); - - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: findings, - }); - - expect(handoff.context.handoffActions).toHaveLength(4); - }); -}); - describe('buildPatrolAssistantFindingHandoff (residual branches)', () => { it('collapses findingId to undefined in both top-level and nested context when no id exists', () => { const handoff = buildPatrolAssistantFindingHandoff({ title: 'T', subject: 'S' }); @@ -967,40 +449,6 @@ describe('buildPatrolAssistantFindingBriefing (residual branches)', () => { }); }); -// --------------------------------------------------------------------------- -// Residual v8 branch arms not reached by the cases above. Each test below -// targets a specific uncovered conditional arm (the `||`/`??` fallback, the -// falsy ternary alternate, or a singular/plural boundary) and asserts a real -// observable value. Arms that are provably unreachable from any public entry -// point are documented in GLM_REPORT_fe-patrolctx.md rather than faked here. -// --------------------------------------------------------------------------- - -describe('buildPatrolConfigurationFailureHandoff (identifier fallback arms)', () => { - it('uses the raw cause verbatim when the identifier formatter collapses it to empty', () => { - // cause '...' is all separator chars: formatIdentifierLabel('...') returns - // '' (falsy) -> `formatIdentifierLabel(cause) || cause` falls back to the - // verbatim cause string. - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - readiness: { cause: '...' }, - }); - - expect(handoff.context.briefing?.detailLines).toContain('Cause: ...'); - expect(handoff.context.context?.readinessCause).toBe('...'); - }); - - it('uses the raw detail key verbatim when the identifier formatter yields nothing', () => { - // formatSafeConfigurationFailureDetail: a key of all separators makes - // formatIdentifierLabel(normalizedKey) return '' -> `|| normalizedKey`. - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'm', - details: { '...': 'plain-value' }, - }); - - expect(handoff.context.briefing?.evidence).toEqual(['...: plain-value']); - }); -}); - describe('buildPatrolAssistantFindingHandoffActions (findingId source fallback)', () => { it('derives the action findingId from the investigation record when the finding has no id', () => { // finding.id absent -> normalizeText(finding.id) is '' (falsy) -> the @@ -1063,130 +511,6 @@ describe('buildPatrolRemediationPlanAssistantBriefing (non-array steps)', () => }); }); -describe('buildPatrolAssessmentAssistantHandoff (correlation filter + sparse endpoints)', () => { - it('keeps a correlation that carries only an event_pattern', () => { - // The correlation filter `||` chain falls through source_id, source_name, - // target_id and target_name (all empty) and is retained by event_pattern. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - correlations: [ - { - event_pattern: 'cpu-spike', - occurrences: 1, - avg_delay: 1, - } as unknown as ResourceCorrelation, - ], - }, - activeFindings: [], - }); - - // Both endpoints resolve to undefined (no source/target ids or names), so - // the context line is kept solely by its pattern/summary facts. - expect(handoff.context.handoffContext).toContain('Correlation 1:'); - expect(handoff.context.handoffContext).toContain('pattern'); - }); - - it('renders a target-only correlation and omits the pattern fact when event_pattern is absent', () => { - // source endpoint undefined, target endpoint present -> `source && target` - // is falsy -> alternate `source || target` evaluates target. With no - // event_pattern the pattern fact takes its `: undefined` arm. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - correlations: [ - { - target_id: 'tgt-1', - target_name: 'Target', - target_type: 'host', - occurrences: 1, - avg_delay: 1, - } as unknown as ResourceCorrelation, - ], - }, - activeFindings: [], - }); - - const line = handoff.context - .handoffContext!.split('\n') - .find((l) => l.startsWith('Correlation 1:'))!; - expect(line).toContain('Target'); - expect(line).not.toContain('pattern'); - }); - - it('falls back to the "Items" metric label when the provided label is whitespace-only', () => { - // primaryLabel ' ' is a truthy string so the caller's `|| 'Active - // findings'` keeps it, but normalizeText trims it to '' inside - // formatAssessmentMetricCount -> `normalizeText(label) || 'Items'`. With a - // singular value the 'Items' label is singularized to 'item'. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - metricState: { primaryLabel: ' ', primaryValue: 1 }, - activeFindings: [], - }); - - expect(handoff.context.briefing?.statusLabel).toContain('1 item'); - }); - - it('renders a single related-resource label without an omitted-count suffix', () => { - // formatAssessmentRelatedResourceList: labels.length === 1 with omittedCount - // 0 -> the bare-label arm (the omittedCount > 0 arm is unreachable here - // because a single visible label always implies zero omitted). - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - supportingEvidence: { - recentChanges: [ - { - id: 'c-1', - observedAt: '2026-01-01T00:00:00Z', - resourceId: 'vm-1', - kind: 'metric_anomaly', - sourceType: 'heuristic', - confidence: 'high', - reason: 'r', - relatedResources: ['solo'], - }, - ], - }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain('related resources solo'); - expect(handoff.context.handoffContext).not.toContain('and 0 more'); - }); - - it('pluralizes the regression count in the assessment finding context line', () => { - // regressionCount > 1 -> the 's' arm of `regressionCount === 1 ? '' : 's'`. - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: [{ id: 'f-1', title: 'High CPU', regressionCount: 3 }], - }); - - const line = handoff.context - .handoffContext!.split('\n') - .find((l) => l.startsWith('Finding 1:'))!; - expect(line).toContain('regressed 3 times'); - }); - - it('reports plural omitted findings when more than one is truncated', () => { - // 7 findings, cap 5 -> 2 omitted -> the 's' arm of the findings-omitted - // ternary. - const findings = Array.from({ length: 7 }, (_, i) => ({ - id: `f-${i + 1}`, - title: `F${i + 1}`, - })); - - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'T' }, - activeFindings: findings, - }); - - expect(handoff.context.handoffContext).toContain( - '2 additional Patrol findings omitted from this bounded handoff summary.', - ); - }); -}); - describe('buildPatrolAssistantFindingHandoff (regression plural arm)', () => { it('pluralizes the regression count in the finding model context', () => { const handoff = buildPatrolAssistantFindingHandoff({ diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 0dd44857b6..3c339612a4 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -1,17 +1,15 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import type { RemediationPlan } from '@/api/ai'; +import type { InvestigationRecord, RemediationPlan } from '@/api/ai'; import type { PatrolRunRecord } from '@/api/patrol'; import { - buildPatrolAssessmentAssistantHandoff, buildPatrolAssistantApprovalBriefingInput, buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingHandoff, buildPatrolAssistantFindingHandoffActions, buildPatrolAssistantProposedFixBriefingInput, - buildPatrolConfigurationFailureHandoff, buildPatrolRunAssistantHandoff, buildPatrolInvestigationRecordPresentation, buildPatrolRemediationPlanAssistantBriefing, @@ -20,565 +18,6 @@ import { } from '../patrolInvestigationContextModel'; describe('patrolInvestigationContextModel', () => { - it('keeps same-state recent changes out of Patrol Assistant no-op wording', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Patrol runtime issue', - description: 'Coverage is incomplete.', - }, - investigationContext: { - recentChangeCount: 1, - correlationCount: 0, - governedResourceCount: 0, - hasContext: true, - summaryText: '1 recent change', - }, - supportingEvidence: { - recentChanges: [ - { - id: 'change-1', - observedAt: '2026-05-06T12:08:00Z', - resourceId: 'app-container-1', - kind: 'state_transition', - from: 'online', - to: 'online', - sourceType: 'pulse_diff', - sourceAdapter: 'docker_adapter', - confidence: 'high', - reason: 'resource state changed', - metadata: { - changedFields: ['docker.command'], - }, - }, - ], - }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain('Docker command changed while online'); - expect(handoff.context.handoffContext).not.toContain('online to online'); - }); - - it("tells a moved alert close in the alert engine's words, not as a recovery", () => { - const summary = - 'Alert moved to pve1 (Host Agent). This is not a recovery: check the agent for the current reading.'; - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'Issues detected' }, - supportingEvidence: { - recentChanges: [ - { - id: 'change-moved', - observedAt: '2026-10-06T15:00:00Z', - resourceId: 'node:pve1', - kind: 'alert_resolved', - sourceType: 'heuristic', - confidence: 'high', - reason: summary, - metadata: { alert_resolution: 'moved_to_agent', alert_type: 'memory' }, - }, - ], - }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain(summary); - expect(handoff.context.handoffContext).not.toContain('Alert resolved'); - expect(handoff.context.briefing?.evidence?.join('\n')).toContain(summary); - }); - - it('includes bounded related-resource context in assessment handoff evidence', () => { - const longRelatedResource = - 'storage-pool-with-a-very-long-description-that-keeps-going-beyond-the-handoff-limit-for-operators'; - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Issues detected', - }, - supportingEvidence: { - recentChanges: [ - { - id: 'change-related', - observedAt: '2026-05-06T12:08:00Z', - resourceId: 'vm-100', - kind: 'metric_anomaly', - sourceType: 'heuristic', - sourceAdapter: 'proxmox_adapter', - confidence: 'high', - reason: 'CPU pressure increased after storage activity', - relatedResources: [ - 'backup-job', - 'cache-node', - longRelatedResource, - 'db-primary', - 'db-replica', - ], - }, - ], - }, - activeFindings: [], - }); - - const relatedEvidence = handoff.context.briefing?.evidence?.find((line) => - line.includes('related resources'), - ); - - expect(relatedEvidence).toContain('related resources backup-job'); - expect(relatedEvidence).toContain('and 1 more'); - expect(relatedEvidence).not.toContain(longRelatedResource); - expect(relatedEvidence).not.toContain('db-replica'); - expect(handoff.context.handoffContext).toContain('related resources backup-job'); - expect(handoff.context.handoffContext).toContain('and 1 more'); - expect(handoff.context.handoffContext).not.toContain(longRelatedResource); - expect(handoff.context.handoffContext).not.toContain('db-replica'); - }); - - it('builds a model-only Assistant handoff for the current Patrol assessment', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Issues detected', - description: - 'Patrol surfaced one active critical finding and recent coverage is incomplete.', - eyebrow: 'Current assessment', - }, - overallHealth: { grade: 'B', score: 84 }, - scoreChipLabel: 'Health', - metricState: { - primaryLabel: 'Infrastructure findings', - primaryValue: 1, - secondaryLabel: 'Runtime issues', - secondaryValue: 1, - fixedLabel: 'Fixed', - fixedValue: 2, - }, - verification: { - title: 'Recently checked', - description: 'Latest full run completed with findings.', - lastFullRunAt: '2026-05-06T12:00:00Z', - activityMixLabel: '1 full check · 2 targeted checks', - }, - recency: { - label: 'Last patrol', - timestamp: '2026-05-06T12:10:00Z', - }, - latestRun: { - kindLabel: 'Patrol check', - status: { label: 'issues found' }, - timestamp: '2026-05-06T12:10:00Z', - coverageSummary: '12 resources checked', - findingsSnapshotAvailable: true, - }, - investigationContext: { - recentChangeCount: 2, - correlationCount: 2, - governedResourceCount: 4, - hasContext: true, - summaryText: '2 recent changes · 2 correlations · 4 policy-covered resources', - }, - supportingEvidence: { - recentChanges: [ - { - id: 'change-1', - observedAt: '2026-05-06T12:08:00Z', - occurredAt: '2026-05-06T12:07:30Z', - resourceId: 'vm-100', - kind: 'metric_anomaly', - sourceType: 'heuristic', - sourceAdapter: 'proxmox_adapter', - confidence: 'high', - actor: 'Pulse Patrol', - relatedResources: ['backup-job'], - reason: 'CPU spike after backup job', - }, - { - id: 'change-2', - observedAt: '2026-05-06T12:07:00Z', - resourceId: 'vm-100', - kind: 'command_executed', - sourceType: 'agent_action', - sourceAdapter: 'agent:ops-helper', - confidence: 'medium', - reason: 'systemctl restart workload.service', - metadata: { - command: 'systemctl restart workload.service', - }, - }, - ], - correlations: [ - { - source_id: 'backup-job', - source_name: 'Nightly backup job', - source_type: 'job', - target_id: 'vm-100', - target_name: 'web-server', - target_type: 'vm', - event_pattern: 'backup_started -> cpu_spike', - occurrences: 4, - avg_delay: 120000000000, - confidence: 0.92, - last_seen: '2026-05-06T12:08:00Z', - description: 'CPU pressure usually follows this backup job.', - }, - ], - }, - activeFindings: [ - { - id: 'finding-1', - title: 'High CPU usage', - description: 'CPU stayed above 95% during backup.', - severity: 'critical', - status: 'active', - resourceId: 'vm-100', - resourceName: 'web-server', - resourceType: 'vm', - detectedAt: '2026-05-06T12:00:00Z', - lastSeenAt: '2026-05-06T12:10:00Z', - investigationStatus: 'completed', - investigationOutcome: 'fix_queued', - loopState: 'awaiting_approval', - timesRaised: 3, - regressionCount: 1, - lastRegressionAt: '2026-05-06T12:06:00Z', - investigationRecord: { - id: 'record-1', - finding_id: 'finding-1', - subject: { - resource_id: 'vm-100', - resource_name: 'web-server', - resource_type: 'vm', - node: 'pve-1', - }, - trigger: { detected_at: '2026-05-06T12:00:00Z' }, - status: 'completed', - outcome: 'fix_queued', - confidence: 'high', - conclusion: 'Backup job saturated CPU.', - recommended_action: 'Approve a controlled restart after the backup completes.', - evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }], - proposed_fix: { - id: 'fix-1', - description: 'Restart the workload service', - commands: ['systemctl restart workload.service'], - risk_level: 'medium', - destructive: false, - }, - verification: ['CPU returned below 50%'], - rollback: [], - tools_used: ['ssh.exec'], - started_at: '2026-05-06T12:00:00Z', - }, - }, - { - id: 'finding-2', - title: 'Patrol provider warning', - severity: 'warning', - status: 'active', - resourceId: 'vm-100', - resourceName: 'web-server', - resourceType: 'vm', - }, - ], - }); - - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.autonomousMode).toBe(false); - expect(handoff.context.handoffContext).toContain('[Patrol Assessment Context]'); - expect(handoff.context.handoffContext).toContain('Source: Pulse Patrol current assessment'); - expect(handoff.context.handoffContext).toContain('Health: Health B 84/100'); - expect(handoff.context.handoffContext).toContain( - 'Supporting Context: 2 recent changes · 2 correlations · 4 policy-covered resources', - ); - expect(handoff.context.handoffContext).toContain( - 'Recent Change 1: Metric anomaly: CPU spike after backup job', - ); - expect(handoff.context.handoffContext).toContain('related resources backup-job'); - expect(handoff.context.briefing?.evidence).toEqual( - expect.arrayContaining([expect.stringContaining('related resources backup-job')]), - ); - expect(handoff.context.handoffContext).toContain( - 'Recent Change 2: Command executed: execution event recorded', - ); - expect(handoff.context.handoffContext).toContain('Correlation 1: Nightly backup job'); - expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage'); - expect(handoff.context.handoffContext).toContain('1 command recorded for approval context'); - expect(handoff.context.handoffResources).toEqual([ - { id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }, - { id: 'backup-job', name: 'Nightly backup job', type: 'job', node: undefined }, - ]); - expect(handoff.context.handoffMetadata).toEqual({ - kind: 'patrol_assessment', - }); - expect(handoff.context.briefing).toMatchObject({ - sourceLabel: 'Pulse Patrol', - title: 'Patrol assessment attached', - subject: 'Issues detected', - actionLabel: '1 governed action reference attached', - safetyNote: - 'Review action posture in the governed flow · raw command payloads stay out of Assistant.', - }); - expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service'); - }); - - it('uses operator-facing finding-record wording for legacy latest-run context', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Patrol reviewed recent activity', - }, - latestRun: { - kindLabel: 'Patrol check', - status: { label: 'completed' }, - timestamp: '2026-05-06T12:10:00Z', - coverageSummary: '67 resources checked', - findingsSnapshotAvailable: false, - }, - activeFindings: [], - }); - - expect(handoff.context.handoffContext).toContain('finding record unavailable'); - expect(handoff.context.handoffContext).not.toContain('findings snapshot unavailable'); - }); - - it('frames coverage-incomplete assessment handoffs as evidence-only context', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Coverage incomplete', - description: - 'Recent Patrol activity only covered targeted checks. Run Patrol to check everything.', - eyebrow: 'Patrol assessment', - }, - overallHealth: { - grade: 'C', - score: 65, - prediction: 'Patrol coverage is incomplete.', - factors: [{ category: 'coverage' }], - }, - scoreChipLabel: 'Assessment', - metricState: { - primaryLabel: 'Active findings', - primaryValue: 0, - secondaryLabel: 'Warnings', - secondaryValue: 0, - fixedLabel: 'Fixed', - fixedValue: 0, - }, - verification: { - title: 'Recently checked', - description: 'The most recent Patrol check completed successfully.', - lastFullRunAt: '2026-05-04T21:38:51Z', - activityMixLabel: '8 full checks, 3 alert-triggered checks', - }, - latestRun: { - kindLabel: 'Targeted check', - status: { label: 'issues found' }, - timestamp: '2026-05-07T21:39:18Z', - coverageSummary: 'Checked 1 of 2 scoped resources', - findingsSnapshotAvailable: true, - }, - investigationContext: { - recentChangeCount: 100, - correlationCount: 29, - governedResourceCount: 116, - hasContext: true, - summaryText: '100 recent changes · 29 correlations · 116 policy-covered resources', - }, - activeFindings: [], - }); - - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.briefing).toMatchObject({ - actionLabel: 'Discuss Patrol coverage', - safetyNote: - 'Assistant can review the coverage evidence. Patrol runs, diagnostics, and remediation remain governed controls.', - }); - expect(handoff.context.briefing?.actionHref).toBeUndefined(); - expect(handoff.context.handoffContext).toContain('Assessment: Coverage incomplete'); - 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', - ); - expect(handoff.context).toMatchObject({ - targetType: 'patrol-assessment', - targetId: 'pulse-patrol-assessment', - autonomousMode: false, - }); - expect(handoff.context.context).not.toHaveProperty('recommendedNextStepTitle'); - expect(handoff.context.context).not.toHaveProperty('recommendedNextStepActionKind'); - expect(handoff.context.handoffMetadata).toEqual({ - kind: 'patrol_assessment', - }); - }); - - it('surfaces active findings as facts rather than Patrol recommendations', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Issues detected', - description: - 'Patrol surfaced one active warning finding in your infrastructure. Review the active findings for more detail.', - eyebrow: 'Patrol assessment', - }, - overallHealth: { - grade: 'B', - score: 85, - prediction: 'Coverage is incomplete for secondary activity.', - factors: [{ category: 'coverage' }], - }, - verification: { - title: 'Recently checked', - description: - 'The most recent Patrol check completed successfully and covered 58 resources.', - lastFullRunAt: '2026-05-12T21:22:35Z', - activityMixLabel: '8 full checks, 3 alert-cleared checks', - }, - latestRun: { - kindLabel: 'Patrol check', - status: { label: 'issues found' }, - timestamp: '2026-05-12T21:22:35Z', - coverageSummary: 'Checked 58 resources', - findingsSnapshotAvailable: true, - }, - investigationContext: { - recentChangeCount: 3, - correlationCount: 70, - governedResourceCount: 55, - hasContext: true, - summaryText: '3 recent changes · 70 correlations · 55 policy-covered resources', - }, - activeFindings: [ - { - id: 'finding-backup', - title: 'Backup failed', - severity: 'warning', - status: 'active', - resourceId: 'backup-delly', - resourceName: 'delly', - resourceType: 'backup', - }, - ], - }); - - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.briefing).toMatchObject({ - actionLabel: 'Discuss Patrol assessment', - safetyNote: 'Use diagnostic tools within current permissions. New actions remain governed.', - }); - expect(handoff.context.handoffContext).not.toContain('Recommended Next Step'); - }); - - it('carries live governed approval posture into assessment finding handoffs', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { - title: 'Issues detected', - }, - activeFindings: [ - { - id: 'finding-1', - title: 'High CPU usage', - severity: 'critical', - status: 'active', - resourceId: 'vm-100', - resourceName: 'web-server', - resourceType: 'vm', - pendingApproval: { - id: 'approval-1', - status: 'pending', - riskLevel: 'high', - requestedAt: '2026-05-06T12:00:00Z', - expiresAt: '2026-05-06T12:10:00Z', - targetName: 'web-server', - actionId: 'action-1', - actionApprovalPolicy: 'admin', - actionPlanExpiresAt: '2026-05-06T12:10:00Z', - actionPlanMessage: 'Restart after the backup window clears.', - actionPreflight: 'Restart workload service', - actionDryRunSummary: 'No provider-supported dry run is available for this action.', - actionRequestedBy: 'pulse_patrol', - }, - proposedFix: { - description: 'Restart the workload service', - riskLevel: 'high', - targetHost: 'web-server', - commandCount: 1, - destructive: true, - }, - }, - ], - }); - - expect(handoff.context.autonomousMode).toBe(false); - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.handoffContext).toContain('Finding 1: High CPU usage'); - expect(handoff.context.handoffContext).toContain('approval approval-1'); - expect(handoff.context.handoffContext).toContain('live approval pending'); - expect(handoff.context.handoffContext).toContain('high risk'); - expect(handoff.context.handoffContext).toContain('approval target web-server'); - expect(handoff.context.handoffContext).toContain('expires 2026-05-06T12:10:00Z'); - expect(handoff.context.handoffContext).toContain('requested by pulse_patrol'); - expect(handoff.context.handoffContext).toContain('1 command recorded for approval context'); - expect(handoff.context.handoffContext).toContain('destructive action artifact'); - expect(handoff.context.handoffActions).toHaveLength(1); - expect(handoff.context.handoffActions?.[0]).toMatchObject({ - findingId: 'finding-1', - approvalId: 'approval-1', - approvalStatus: 'pending', - approvalRequestedAt: '2026-05-06T12:00:00Z', - approvalExpiresAt: '2026-05-06T12:10:00Z', - actionId: 'action-1', - actionRequestedBy: 'pulse_patrol', - actionApprovalPolicy: 'admin', - actionRequiresApproval: true, - actionPlanExpiresAt: '2026-05-06T12:10:00Z', - actionPlanMessage: 'Restart after the backup window clears.', - actionPreflight: 'Restart workload service', - actionDryRunSummary: 'No provider-supported dry run is available for this action.', - description: 'Restart the workload service', - riskLevel: 'high', - destructive: true, - targetHost: 'web-server', - targetResourceId: 'vm-100', - targetResourceName: 'web-server', - targetResourceType: 'vm', - }); - expect(handoff.context.context).toMatchObject({ - pendingApprovalCount: 1, - }); - expect(handoff.context.briefing).toMatchObject({ - actionLabel: '1 pending governed approval attached', - 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(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service'); - }); - - it('keeps a typed pending action approval-bound without a legacy approval id', () => { - const handoff = buildPatrolAssessmentAssistantHandoff({ - assessment: { title: 'Issues detected' }, - activeFindings: [ - { - id: 'finding-typed-action', - title: 'Unhealthy workload', - severity: 'warning', - status: 'active', - resourceId: 'docker:container:web', - pendingApproval: { - id: '', - status: 'pending_approval', - riskLevel: 'governed', - requestedAt: '2026-07-10T18:00:00Z', - actionId: 'action-typed-1', - actionApprovalPolicy: 'admin', - actionRequestedBy: 'pulse_patrol', - }, - }, - ], - }); - - expect(handoff.context.handoffActions?.[0]).toMatchObject({ - findingId: 'finding-typed-action', - actionId: 'action-typed-1', - actionApprovalPolicy: 'admin', - actionRequiresApproval: true, - }); - expect(handoff.context.handoffActions?.[0].approvalId).toBeUndefined(); - }); - it('builds a model-only Assistant handoff for a Patrol run runtime failure', () => { const run: PatrolRunRecord = { id: 'run-runtime-error', @@ -656,72 +95,6 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(handoff)).not.toContain('No endpoints found'); }); - it('builds a model-only Assistant handoff for a Patrol mode save failure', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - message: 'The selected Patrol model is a reasoning-only model family.', - code: 'patrol_readiness_not_ready', - status: 400, - details: { - cause: 'model_unsupported_tools', - provider: 'ollama', - command: 'systemctl restart pulse.service', - }, - autonomyLevel: 'approval', - fullModeUnlocked: false, - investigationBudget: 10, - investigationTimeoutSec: 120, - readiness: { - status: 'not_ready', - cause: 'model_unsupported_tools', - summary: 'The selected Patrol model is a reasoning-only model family.', - provider: 'ollama', - model: 'ollama:deepseek-r1:7b', - }, - runtimeState: 'active', - }); - - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.autonomousMode).toBe(false); - expect(handoff.context.handoffMetadata).toMatchObject({ - kind: 'patrol_configuration_failure', - runtimeFailure: true, - }); - expect(handoff.context.handoffContext).toContain('[Patrol Control Save Failure Context]'); - expect(handoff.context.handoffContext).toContain('Server Code: patrol_readiness_not_ready'); - expect(handoff.context.handoffContext).toContain('Provider: ollama'); - expect(handoff.context.handoffContext).toContain('Model: ollama:deepseek-r1:7b'); - expect(handoff.context.handoffContext).toContain( - 'Command: sensitive or command detail withheld', - ); - expect(handoff.context.briefing).toMatchObject({ - sourceLabel: 'Pulse Patrol', - title: 'Patrol mode save failure attached', - actionLabel: 'Review Patrol mode save failure', - }); - expect(JSON.stringify(handoff)).not.toContain('systemctl restart pulse.service'); - }); - - it('labels saved Patrol mode readiness issues separately from save failures', () => { - const handoff = buildPatrolConfigurationFailureHandoff({ - saved: true, - message: 'Patrol was saved, but the selected model cannot run Patrol tools yet.', - code: 'patrol_readiness_not_ready', - readiness: { - status: 'not_ready', - cause: 'model_unsupported_tools', - summary: 'The selected Patrol model cannot run tools.', - provider: 'ollama', - model: 'ollama:deepseek-r1:7b', - }, - }); - - expect(handoff).not.toHaveProperty('prompt'); - expect(handoff.context.briefing).toMatchObject({ - title: 'Patrol mode issue attached', - actionLabel: 'Review Patrol mode issue', - }); - }); - it('builds operator-facing Patrol record presentation without exposing raw commands', () => { const presentation = buildPatrolInvestigationRecordPresentation({ id: 'record-1', @@ -978,6 +351,33 @@ describe('patrolInvestigationContextModel', () => { ).toEqual([]); }); + it('keeps a typed pending action approval-bound without a legacy approval id', () => { + const [action] = buildPatrolAssistantFindingHandoffActions({ + id: 'finding-typed-action', + title: 'Unhealthy workload', + severity: 'warning', + status: 'active', + resourceId: 'docker:container:web', + pendingApproval: { + id: '', + status: 'pending_approval', + riskLevel: 'governed', + requestedAt: '2026-07-10T18:00:00Z', + actionId: 'action-typed-1', + actionApprovalPolicy: 'admin', + actionRequestedBy: 'pulse_patrol', + }, + }); + + expect(action).toMatchObject({ + findingId: 'finding-typed-action', + actionId: 'action-typed-1', + actionApprovalPolicy: 'admin', + actionRequiresApproval: true, + }); + expect(action.approvalId).toBeUndefined(); + }); + it('builds finding-level Assistant handoff context, resources, and actions together', () => { const handoff = buildPatrolAssistantFindingHandoff({ id: 'finding-1', @@ -1050,6 +450,34 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(handoff)).not.toContain('systemctl restart nginx'); }); + it('qualifies the finding resource with its record node and bounds long context lines', () => { + const description = 'Disk latency spiked during the backup window. '.repeat(20).trim(); + const handoff = buildPatrolAssistantFindingHandoff({ + id: 'finding-node', + title: 'Disk latency', + subject: 'web-server', + description, + investigationRecord: { + id: 'record-node', + finding_id: 'finding-node', + subject: { + resource_id: 'vm-100', + resource_name: 'web-server', + resource_type: 'vm', + node: 'pve1', + }, + status: 'completed', + } as unknown as InvestigationRecord, + }); + + const lines = handoff.context.handoffContext?.split('\n') ?? []; + expect(lines).toContain('Resource: web-server (vm vm-100 node pve1)'); + const descriptionLine = lines.find((line) => line.startsWith('Description: ')); + expect(description.length).toBeGreaterThan(500); + expect(descriptionLine?.endsWith('...')).toBe(true); + expect(descriptionLine?.slice('Description: '.length).length).toBeLessThanOrEqual(500); + }); + it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => { // Pin "now" so the relative-time formatting in the briefing // (`last regression {relative}`) is deterministic against the diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 8a00db33f5..ab961032a5 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -1,4 +1,3 @@ -import type { ResourceCorrelation } from '@/types/aiIntelligence'; import type { ApprovalRequest, InvestigationRecord, RemediationPlan } from '@/api/ai'; import type { PatrolRunRecord } from '@/api/patrol'; import type { AttentionItemDetail } from '@/api/patrolAttention'; @@ -9,19 +8,6 @@ import type { AIChatHandoffAction, AIChatHandoffResource, } from '@/stores/aiChat'; -import type { ResourceChange } from '@/types/resource'; -import { - formatResourceChangeHeadline, - formatResourceChangeKind, - getResourceChangeAlertResolution, - sortResourceChangesByObservedAt, -} from '@/utils/resourceChangePresentation'; -import { - formatResourceCorrelationEndpoint, - formatResourceCorrelationPattern, - formatResourceCorrelationSummary, - sortResourceCorrelations, -} from '@/utils/resourceCorrelationPresentation'; import { getFindingSubjectPresentation, getFindingTitlePresentation, @@ -53,14 +39,6 @@ function formatBriefingTimestamp(value: string | undefined): string { return formatRelativeTime(value, { compact: false, emptyText: '' }); } -export interface PatrolInvestigationContextSummary { - recentChangeCount: number; - correlationCount: number; - governedResourceCount: number; - hasContext: boolean; - summaryText: string; -} - export interface PatrolInvestigationRecordPresentation { hasRecord: boolean; statusLabel: string; @@ -196,58 +174,6 @@ export interface PatrolAssessmentAssistantFindingInput { investigationRecord?: InvestigationRecord | null; } -export interface PatrolAssessmentAssistantHandoffInput { - assessment?: { - title?: string | null; - description?: string | null; - eyebrow?: string | null; - } | null; - overallHealth?: { - factors?: Array<{ category?: string | null }> | null; - grade?: string | null; - prediction?: string | null; - score?: number | null; - } | null; - scoreChipLabel?: string | null; - metricState?: { - primaryLabel?: string | null; - primaryValue?: number | null; - secondaryLabel?: string | null; - secondaryValue?: number | null; - fixedLabel?: string | null; - fixedValue?: number | null; - } | null; - verification?: { - title?: string | null; - description?: string | null; - lastFullRunAt?: string | null; - activityMixLabel?: string | null; - } | null; - recency?: { - label?: string | null; - timestamp?: string | null; - } | null; - latestRun?: { - kindLabel?: string | null; - status?: { - label?: string | null; - } | null; - timestamp?: string | null; - coverageSummary?: string | null; - findingsSnapshotAvailable?: boolean | null; - } | null; - investigationContext?: PatrolInvestigationContextSummary | null; - supportingEvidence?: { - recentChanges?: ResourceChange[] | null; - correlations?: ResourceCorrelation[] | null; - } | null; - activeFindings?: PatrolAssessmentAssistantFindingInput[] | null; -} - -export interface PatrolAssessmentAssistantHandoff { - context: AIChatContext; -} - export interface PatrolAssistantFindingHandoff { context: AIChatContext; } @@ -277,32 +203,7 @@ export interface PatrolConfigurationFailureInput { blockedReason?: string; blockedCause?: string; } - -export interface PatrolConfigurationFailureHandoff { - context: AIChatContext; -} - -const MAX_ASSESSMENT_FINDINGS = 5; -const MAX_ASSESSMENT_RECENT_CHANGES = 3; -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_ASSESSMENT_RELATED_RESOURCE_LABELS = 4; -const MAX_ASSESSMENT_RELATED_RESOURCE_LABEL_LENGTH = 80; -const SAME_STATE_CHANGED_FIELD_LABELS: Record = { - 'docker.command': 'Docker command', - 'docker.updateStatus': 'Docker image status', - 'proxmox.lifecycle': 'Proxmox lifecycle', - status: 'status', - incidents: 'incident state', - relationships: 'relationships', - capabilities: 'capabilities', - tags: 'tags', - parentId: 'parent relationship', - customUrl: 'custom URL', - identity: 'identity', -}; export function buildPatrolInvestigationRecordPresentation( record?: InvestigationRecord | null, @@ -550,40 +451,6 @@ export function buildPatrolAttentionAssistantHandoff( }; } -export function buildPatrolAssessmentAssistantHandoff( - input: PatrolAssessmentAssistantHandoffInput, -): PatrolAssessmentAssistantHandoff { - const handoffContext = buildPatrolAssessmentAssistantModelContext(input); - const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); - const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); - const handoffActions = buildPatrolAssessmentHandoffActions(input); - - return { - context: { - targetType: 'patrol-assessment', - targetId: 'pulse-patrol-assessment', - autonomousMode: false, - handoffContext, - handoffResources: buildPatrolAssessmentHandoffResources(input), - handoffActions: handoffActions.length > 0 ? handoffActions : undefined, - handoffMetadata: { - kind: 'patrol_assessment', - }, - briefing: buildPatrolAssessmentAssistantBriefing(input), - context: { - source: 'pulse-patrol-assessment', - activeFindingCount: normalizeNonNegativeCount(input.activeFindings?.length), - recentChangeCount: input.investigationContext?.recentChangeCount ?? 0, - correlationCount: input.investigationContext?.correlationCount ?? 0, - recentChangeDetailCount: recentChanges.length, - correlationDetailCount: correlations.length, - governedResourceCount: input.investigationContext?.governedResourceCount ?? 0, - pendingApprovalCount: normalizeAssessmentPendingApprovalCount(input.activeFindings), - }, - }, - }; -} - export function buildPatrolAssistantFindingHandoff( input: PatrolAssistantFindingHandoffInput, ): PatrolAssistantFindingHandoff { @@ -674,62 +541,6 @@ export function buildPatrolRunAssistantHandoff(run: PatrolRunRecord): PatrolRunA }; } -export function buildPatrolConfigurationFailureHandoff( - input: PatrolConfigurationFailureInput, -): PatrolConfigurationFailureHandoff { - const message = normalizeText(input.message) || 'Patrol mode could not be saved.'; - const code = normalizeText(input.code); - const issueLabel = input.saved ? 'Patrol mode issue' : 'Patrol mode save failure'; - const readinessSummary = normalizeText(input.readiness?.summary); - const cause = normalizeText(input.readiness?.cause) || normalizeText(input.blockedCause); - const provider = normalizeText(input.readiness?.provider); - const model = normalizeText(input.readiness?.model); - const detailLines = [ - readinessSummary ? `Readiness: ${readinessSummary}` : undefined, - cause ? `Cause: ${formatIdentifierLabel(cause) || cause}` : undefined, - provider ? `Provider: ${provider}` : undefined, - model ? `Model: ${model}` : undefined, - formatConfigurationFailureSettings(input), - ].filter(isNonEmptyString); - - return { - context: { - targetType: 'patrol-configuration', - targetId: 'pulse-patrol-configuration', - autonomousMode: false, - handoffContext: buildPatrolConfigurationFailureModelContext(input, message, detailLines), - handoffMetadata: { - kind: 'patrol_configuration_failure', - runtimeFailure: true, - }, - briefing: { - sourceLabel: 'Pulse Patrol', - title: `${issueLabel.charAt(0).toUpperCase()}${issueLabel.slice(1)} attached`, - subject: code ? `${code}: ${message}` : message, - statusLabel: - [input.status ? `HTTP ${input.status}` : undefined, cause] - .filter(isNonEmptyString) - .join(' · ') || undefined, - detailLines: detailLines.slice(0, 4), - evidence: formatConfigurationFailureDetails(input.details).slice(0, 4), - actionLabel: `Review ${issueLabel}`, - safetyNote: - 'Assistant can explain the Patrol mode state. Provider changes, retries, and remediation remain operator-controlled.', - }, - context: { - source: 'pulse-patrol-configuration-failure', - code: code || undefined, - status: input.status, - readinessStatus: normalizeText(input.readiness?.status) || undefined, - readinessCause: cause || undefined, - provider: provider || undefined, - model: model || undefined, - runtimeState: normalizeText(input.runtimeState) || undefined, - }, - }, - }; -} - export function buildPatrolAssistantFindingHandoffActions( finding: PatrolAssessmentAssistantFindingInput, ): AIChatHandoffAction[] { @@ -737,205 +548,6 @@ export function buildPatrolAssistantFindingHandoffActions( return action ? [action] : []; } -function buildPatrolAssessmentAssistantBriefing( - input: PatrolAssessmentAssistantHandoffInput, -): AIChatContextBriefing { - const title = normalizeText(input.assessment?.title) || 'Pulse Patrol assessment'; - const description = normalizeText(input.assessment?.description); - const health = formatAssessmentHealth(input); - const attentionSummary = formatAssessmentAttentionSummary(input); - const verification = formatAssessmentVerification(input); - const latestRun = formatAssessmentLatestRun(input); - const contextSummary = normalizeText(input.investigationContext?.summaryText); - const findings = normalizeAssessmentFindings(input.activeFindings); - const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); - const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); - const findingEvidence = findings.map(formatAssessmentFindingEvidence).filter(isNonEmptyString); - const handoffActions = buildPatrolAssessmentHandoffActions(input); - const actionPosture = buildPatrolAssessmentActionPosture(input, handoffActions); - const supportingEvidence = [ - ...recentChanges.map(formatAssessmentRecentChangeEvidence), - ...correlations.map(formatAssessmentCorrelationEvidence), - ] - .filter(isNonEmptyString) - .slice(0, 2); - - return { - sourceLabel: 'Pulse Patrol', - title: 'Patrol assessment attached', - subject: title, - statusLabel: [health, attentionSummary].filter(isNonEmptyString).join(' · ') || undefined, - 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, - }; -} - -function buildPatrolAssessmentActionPosture( - input: PatrolAssessmentAssistantHandoffInput, - handoffActions: AIChatHandoffAction[], -): { actionLabel: string; actionHref?: string; safetyNote: string } { - const pendingApprovalCount = normalizeAssessmentPendingApprovalCount(input.activeFindings); - const actionCount = handoffActions.length; - const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); - const hasCoverageGap = assessmentHasCoverageGap(input); - const hasCoverageOnlyGap = hasCoverageGap && activeFindingCount === 0; - const hasDryRunPosture = handoffActions.some((action) => - Boolean(normalizeText(action.actionDryRunSummary) || normalizeText(action.actionPreflight)), - ); - const hasDestructiveAction = handoffActions.some((action) => Boolean(action.destructive)); - const hasApprovalPolicy = handoffActions.some((action) => - Boolean(normalizeText(action.actionApprovalPolicy)), - ); - - if (pendingApprovalCount > 0) { - return { - actionLabel: `${formatAssessmentMetricCount( - 'Pending governed approvals', - pendingApprovalCount, - )} attached`, - safetyNote: formatAssessmentActionSafetyNote({ - primary: 'Review approvals in the governed flow', - hasDryRunPosture, - hasDestructiveAction, - hasApprovalPolicy, - }), - }; - } - - if (actionCount > 0) { - return { - actionLabel: `${formatAssessmentMetricCount( - 'Governed action references', - actionCount, - )} attached`, - safetyNote: formatAssessmentActionSafetyNote({ - primary: 'Review action posture in the governed flow', - hasDryRunPosture, - hasDestructiveAction, - hasApprovalPolicy, - }), - }; - } - - if (hasCoverageOnlyGap) { - return { - actionLabel: 'Discuss Patrol coverage', - safetyNote: - 'Assistant can review the coverage evidence. Patrol runs, diagnostics, and remediation remain governed controls.', - }; - } - - return { - actionLabel: 'Discuss Patrol assessment', - safetyNote: 'Use diagnostic tools within current permissions. New actions remain governed.', - }; -} - -function formatAssessmentActionSafetyNote(input: { - primary: string; - hasDryRunPosture: boolean; - hasDestructiveAction: boolean; - hasApprovalPolicy: boolean; -}): string { - const parts = [input.primary]; - if (input.hasApprovalPolicy) { - parts.push('approval policy is attached'); - } - if (input.hasDryRunPosture) { - parts.push('dry-run posture is attached'); - } - if (input.hasDestructiveAction) { - parts.push('destructive actions remain approval-bound'); - } - parts.push('raw command payloads stay out of Assistant'); - return `${parts.join(' · ')}.`; -} - -function assessmentHasCoverageGap(input: PatrolAssessmentAssistantHandoffInput): boolean { - const title = normalizeText(input.assessment?.title).toLowerCase(); - const description = normalizeText(input.assessment?.description).toLowerCase(); - const prediction = normalizeText(input.overallHealth?.prediction).toLowerCase(); - const hasCoverageFactor = Boolean( - input.overallHealth?.factors?.some( - (factor) => normalizeText(factor.category).toLowerCase() === 'coverage', - ), - ); - - return ( - hasCoverageFactor || - title.includes('coverage incomplete') || - description.includes('coverage incomplete') || - description.includes('coverage is incomplete') || - prediction.includes('coverage incomplete') || - prediction.includes('coverage is incomplete') - ); -} - -function buildPatrolAssessmentAssistantModelContext( - input: PatrolAssessmentAssistantHandoffInput, -): string { - const findings = normalizeAssessmentFindings(input.activeFindings); - const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); - const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); - const totalFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); - const omittedFindingCount = Math.max(0, totalFindingCount - findings.length); - const totalRecentChangeCount = Math.max( - normalizeNonNegativeCount(input.investigationContext?.recentChangeCount), - (input.supportingEvidence?.recentChanges ?? []).length, - ); - const omittedRecentChangeCount = Math.max(0, totalRecentChangeCount - recentChanges.length); - const totalCorrelationCount = Math.max( - normalizeNonNegativeCount(input.investigationContext?.correlationCount), - (input.supportingEvidence?.correlations ?? []).length, - ); - const omittedCorrelationCount = Math.max(0, totalCorrelationCount - correlations.length); - - return [ - '[Patrol Assessment Context]', - 'Source: Pulse Patrol current assessment', - formatContextLine( - 'Assessment', - normalizeText(input.assessment?.title) || 'Pulse Patrol assessment', - ), - formatContextLine('Assessment Description', input.assessment?.description), - formatContextLine('Assessment Scope', input.assessment?.eyebrow), - formatContextLine('Health', formatAssessmentHealth(input)), - formatContextLine('Attention', formatAssessmentAttentionSummary(input)), - formatContextLine('Verification', formatAssessmentVerification(input)), - formatContextLine('Last Patrol', formatAssessmentRecency(input)), - formatContextLine('Latest Run', formatAssessmentLatestRun(input)), - formatContextLine('Supporting Context', input.investigationContext?.summaryText), - ...recentChanges.map((change, index) => - formatAssessmentRecentChangeContextLine(change, index + 1), - ), - omittedRecentChangeCount > 0 - ? `${omittedRecentChangeCount} additional recent change${ - omittedRecentChangeCount === 1 ? '' : 's' - } omitted from this bounded handoff summary.` - : undefined, - ...correlations.map((correlation, index) => - formatAssessmentCorrelationContextLine(correlation, index + 1), - ), - omittedCorrelationCount > 0 - ? `${omittedCorrelationCount} additional correlation${ - omittedCorrelationCount === 1 ? '' : 's' - } omitted from this bounded handoff summary.` - : undefined, - ...findings.map((finding, index) => formatAssessmentFindingContextLine(finding, index + 1)), - omittedFindingCount > 0 - ? `${omittedFindingCount} additional Patrol finding${omittedFindingCount === 1 ? '' : 's'} omitted from this bounded handoff summary.` - : undefined, - 'Model Boundary: This Patrol assessment handoff is model-only context for explanation and review. Use available diagnostic tools within current permissions. This handoff grants no new action authority or permission to retry a refused provider path.', - ] - .filter(isNonEmptyString) - .join('\n'); -} - function buildPatrolRunAssistantBriefing( run: PatrolRunRecord, kindLabel: string, @@ -970,75 +582,6 @@ function buildPatrolRunAssistantBriefing( }; } -function buildPatrolConfigurationFailureModelContext( - input: PatrolConfigurationFailureInput, - message: string, - detailLines: string[], -): string { - const details = formatConfigurationFailureDetails(input.details); - return [ - '[Patrol Control Save Failure Context]', - 'Source: Pulse Patrol mode surface', - formatContextLine('Server Message', message), - formatContextLine('Server Code', input.code), - input.status ? `HTTP Status: ${input.status}` : undefined, - ...detailLines.map((line) => formatContextLine('Control Detail', line)), - ...details.map((line) => formatContextLine('Backend Detail', line)), - 'Model Boundary: This Patrol mode handoff is model-only context for explanation and review. Provider changes, retries, diagnostics, remediation, and command execution require explicit governed operator action.', - ] - .filter(isNonEmptyString) - .join('\n'); -} - -function formatConfigurationFailureSettings( - input: PatrolConfigurationFailureInput, -): string | undefined { - const settings = [ - input.autonomyLevel ? `mode ${input.autonomyLevel}` : undefined, - typeof input.fullModeUnlocked === 'boolean' - ? `automatic critical fixes ${input.fullModeUnlocked ? 'enabled' : 'disabled'}` - : undefined, - typeof input.investigationBudget === 'number' - ? `budget ${input.investigationBudget}` - : undefined, - typeof input.investigationTimeoutSec === 'number' - ? `timeout ${input.investigationTimeoutSec}s` - : undefined, - ].filter(isNonEmptyString); - - return settings.length > 0 ? `Requested settings: ${settings.join(', ')}` : undefined; -} - -function formatConfigurationFailureDetails(details?: Record): string[] { - if (!details) return []; - return Object.entries(details) - .map(([key, value]) => formatSafeConfigurationFailureDetail(key, value)) - .filter(isNonEmptyString) - .slice(0, 6); -} - -function formatSafeConfigurationFailureDetail(key: string, value: string): string | undefined { - const normalizedKey = normalizeText(key); - const normalizedValue = normalizeText(value); - if (!normalizedKey || !normalizedValue) return undefined; - - const label = formatIdentifierLabel(normalizedKey) || normalizedKey; - if (configurationFailureDetailShouldBeWithheld(normalizedKey, normalizedValue)) { - return `${label}: sensitive or command detail withheld`; - } - - return `${label}: ${truncateContextText(normalizedValue, 180)}`; -} - -function configurationFailureDetailShouldBeWithheld(key: string, value: string): boolean { - const normalizedKey = key.toLowerCase(); - const normalizedValue = value.toLowerCase(); - return ( - /(password|secret|token|api[_-]?key|credential|command|script|shell)/.test(normalizedKey) || - /\b(systemctl|sudo|bash|sh\s+-c|curl|wget|kubectl|docker|ssh)\b/.test(normalizedValue) - ); -} - function buildPatrolRunHandoffResources(run: PatrolRunRecord): AIChatHandoffResource[] { const type = run.scope_resource_types?.length === 1 ? normalizeText(run.scope_resource_types[0]) : ''; @@ -1176,44 +719,6 @@ function formatPatrolRunEffort(run: PatrolRunRecord): string | undefined { ); } -function buildPatrolAssessmentHandoffResources( - input: PatrolAssessmentAssistantHandoffInput, -): AIChatHandoffResource[] { - const resources = new Map(); - const recentChanges = normalizeAssessmentRecentChanges(input.supportingEvidence?.recentChanges); - const correlations = normalizeAssessmentCorrelations(input.supportingEvidence?.correlations); - - for (const finding of normalizeAssessmentFindings(input.activeFindings)) { - addAssessmentHandoffResource(resources, getAssessmentFindingResource(finding)); - } - - for (const change of recentChanges) { - addAssessmentHandoffResource(resources, { - id: normalizeText(change.resourceId), - }); - for (const relatedResource of change.relatedResources ?? []) { - addAssessmentHandoffResource(resources, { - id: normalizeText(relatedResource), - }); - } - } - - for (const correlation of correlations) { - addAssessmentHandoffResource(resources, { - id: normalizeText(correlation.source_id), - name: normalizeText(correlation.source_name) || undefined, - type: normalizeText(correlation.source_type) || undefined, - }); - addAssessmentHandoffResource(resources, { - id: normalizeText(correlation.target_id), - name: normalizeText(correlation.target_name) || undefined, - type: normalizeText(correlation.target_type) || undefined, - }); - } - - return Array.from(resources.values()); -} - function buildPatrolFindingHandoffResource( input: PatrolAssistantFindingHandoffInput, ): AIChatHandoffResource | undefined { @@ -1233,54 +738,6 @@ function buildPatrolFindingHandoffResource( }; } -function addAssessmentHandoffResource( - resources: Map, - resource: AIChatHandoffResource, -): void { - const id = normalizeText(resource.id); - if (!id) return; - - const normalizedResource: AIChatHandoffResource = { - id, - name: normalizeText(resource.name) || undefined, - type: normalizeText(resource.type) || undefined, - node: normalizeText(resource.node) || undefined, - }; - const existingEntry = Array.from(resources.entries()).find(([, existing]) => { - if (existing.id !== id) return false; - if (!normalizedResource.type || !existing.type) return true; - return existing.type === normalizedResource.type; - }); - const key = - existingEntry?.[0] || - [normalizedResource.type, normalizedResource.id].filter(isNonEmptyString).join(':') || - id; - const existing = existingEntry?.[1] || resources.get(key); - - if (!existing && resources.size >= MAX_ASSESSMENT_RESOURCES) return; - - resources.set(key, { - id, - name: existing?.name || normalizedResource.name, - type: existing?.type || normalizedResource.type, - node: existing?.node || normalizedResource.node, - }); -} - -function buildPatrolAssessmentHandoffActions( - input: PatrolAssessmentAssistantHandoffInput, -): AIChatHandoffAction[] { - const actions: AIChatHandoffAction[] = []; - - for (const finding of normalizeAssessmentFindings(input.activeFindings)) { - if (actions.length >= MAX_ASSESSMENT_HANDOFF_ACTIONS) break; - const action = buildPatrolFindingHandoffAction(finding); - if (action) actions.push(action); - } - - return actions; -} - function buildPatrolFindingHandoffAction( finding: PatrolAssessmentAssistantFindingInput, ): AIChatHandoffAction | undefined { @@ -1342,481 +799,6 @@ function buildPatrolFindingHandoffAction( }; } -function normalizeAssessmentFindings( - findings?: PatrolAssessmentAssistantFindingInput[] | null, -): PatrolAssessmentAssistantFindingInput[] { - return (findings ?? []) - .filter((finding) => - Boolean( - normalizeText(finding.id) || - normalizeText(finding.title) || - normalizeText(finding.resourceId) || - normalizeText(finding.investigationRecord?.id), - ), - ) - .slice(0, MAX_ASSESSMENT_FINDINGS); -} - -function normalizeAssessmentPendingApprovalCount( - findings?: PatrolAssessmentAssistantFindingInput[] | null, -): number { - return (findings ?? []).filter((finding) => { - const approval = normalizeApprovalBriefing(finding.pendingApproval); - return approval.id && approval.status === 'pending'; - }).length; -} - -function normalizeAssessmentRecentChanges(changes?: ResourceChange[] | null): ResourceChange[] { - return sortResourceChangesByObservedAt( - (changes ?? []).filter((change) => - Boolean( - normalizeText(change.id) || normalizeText(change.resourceId) || normalizeText(change.kind), - ), - ), - ) - .map(normalizePatrolSupportingRecentChange) - .slice(0, MAX_ASSESSMENT_RECENT_CHANGES); -} - -function normalizePatrolSupportingRecentChange(change: ResourceChange): ResourceChange { - if (!isSameStateTransition(change)) { - return change; - } - - const reason = formatSameStateTransitionReason(change); - return { - ...change, - from: undefined, - to: undefined, - reason, - }; -} - -function isSameStateTransition(change: ResourceChange): boolean { - if (change.kind !== 'state_transition' && change.kind !== 'restart') { - return false; - } - const from = normalizeText(change.from).toLowerCase(); - const to = normalizeText(change.to).toLowerCase(); - return Boolean(from && to && from === to); -} - -function formatSameStateTransitionReason(change: ResourceChange): string { - const state = normalizeText(change.from) || normalizeText(change.to) || 'current state'; - const changedFieldLabels = getSameStateChangedFieldLabels(change); - if (changedFieldLabels.length > 0) { - return `${formatCompactLabelList(changedFieldLabels)} changed while ${state}`; - } - - const reason = normalizeText(change.reason); - if (reason && reason.toLowerCase() !== 'resource state changed') { - return `${reason} while ${state}`; - } - - return `state details changed while ${state}`; -} - -function getSameStateChangedFieldLabels(change: ResourceChange): string[] { - const changedFields = change.metadata?.changedFields; - if (!Array.isArray(changedFields)) { - return []; - } - - const labels: string[] = []; - for (const field of changedFields) { - const key = normalizeText(String(field)); - if (!key) continue; - const label = SAME_STATE_CHANGED_FIELD_LABELS[key] ?? formatIdentifierLabel(key); - if (label && !labels.includes(label)) { - labels.push(label); - } - } - return labels; -} - -function formatCompactLabelList(labels: string[]): string { - if (labels.length === 0) return ''; - if (labels.length === 1) return labels[0]; - if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; - return `${labels[0]}, ${labels[1]}, and ${labels.length - 2} more`; -} - -function normalizeAssessmentCorrelations( - correlations?: ResourceCorrelation[] | null, -): ResourceCorrelation[] { - return sortResourceCorrelations( - (correlations ?? []).filter((correlation) => - Boolean( - normalizeText(correlation.source_id) || - normalizeText(correlation.source_name) || - normalizeText(correlation.target_id) || - normalizeText(correlation.target_name) || - normalizeText(correlation.event_pattern), - ), - ), - ).slice(0, MAX_ASSESSMENT_CORRELATIONS); -} - -function formatAssessmentHealth(input: PatrolAssessmentAssistantHandoffInput): string | undefined { - const label = normalizeText(input.scoreChipLabel) || 'Health'; - const grade = normalizeText(input.overallHealth?.grade); - const score = input.overallHealth?.score; - const scoreLabel = - typeof score === 'number' && Number.isFinite(score) ? `${Math.round(score)}/100` : ''; - return [label, grade, scoreLabel].filter(isNonEmptyString).join(' ') || undefined; -} - -function formatAssessmentAttentionSummary( - input: PatrolAssessmentAssistantHandoffInput, -): string | undefined { - const metricState = input.metricState; - const parts: string[] = []; - const primaryValue = normalizeNonNegativeCount(metricState?.primaryValue); - const secondaryValue = normalizeNonNegativeCount(metricState?.secondaryValue); - const fixedValue = normalizeNonNegativeCount(metricState?.fixedValue); - - if (primaryValue > 0) { - parts.push( - formatAssessmentMetricCount(metricState?.primaryLabel || 'Active findings', primaryValue), - ); - } - if ( - secondaryValue > 0 && - normalizeText(metricState?.secondaryLabel) !== normalizeText(metricState?.primaryLabel) - ) { - parts.push( - formatAssessmentMetricCount(metricState?.secondaryLabel || 'Warnings', secondaryValue), - ); - } - if (fixedValue > 0) { - parts.push(formatAssessmentMetricCount(metricState?.fixedLabel || 'Fixed', fixedValue)); - } - - if (parts.length === 0) { - const activeFindingCount = normalizeNonNegativeCount(input.activeFindings?.length); - return activeFindingCount > 0 - ? formatAssessmentMetricCount('Active findings', activeFindingCount) - : 'No active Patrol findings'; - } - - return parts.join(' · '); -} - -function formatAssessmentMetricCount(label: string | null | undefined, value: number): string { - const normalizedLabel = normalizeText(label) || 'Items'; - const displayLabel = value === 1 ? singularizeMetricLabel(normalizedLabel) : normalizedLabel; - return `${value} ${displayLabel.toLowerCase()}`; -} - -function singularizeMetricLabel(label: string): string { - if (label.toLowerCase().endsWith('ies')) { - return `${label.slice(0, -3)}y`; - } - if (label.toLowerCase().endsWith('s')) { - return label.slice(0, -1); - } - return label; -} - -function formatAssessmentVerification( - input: PatrolAssessmentAssistantHandoffInput, -): string | undefined { - const verification = input.verification; - if (!verification) return undefined; - - return [ - normalizeText(verification.title), - normalizeText(verification.description), - normalizeText(verification.lastFullRunAt) - ? `last full run ${normalizeText(verification.lastFullRunAt)}` - : undefined, - normalizeText(verification.activityMixLabel) - ? `recent activity mix ${normalizeText(verification.activityMixLabel)}` - : undefined, - ] - .filter(isNonEmptyString) - .join(' · '); -} - -function formatAssessmentRecency(input: PatrolAssessmentAssistantHandoffInput): string | undefined { - const label = normalizeText(input.recency?.label); - const timestamp = normalizeText(input.recency?.timestamp); - if (!label && !timestamp) return undefined; - return [label || 'Last Patrol', timestamp].filter(isNonEmptyString).join(' '); -} - -function formatAssessmentLatestRun( - input: PatrolAssessmentAssistantHandoffInput, -): string | undefined { - const latestRun = input.latestRun; - if (!latestRun) return undefined; - - return [ - normalizeText(latestRun.kindLabel), - normalizeText(latestRun.status?.label), - normalizeText(latestRun.timestamp), - normalizeText(latestRun.coverageSummary), - latestRun.findingsSnapshotAvailable === false ? 'finding record unavailable' : undefined, - ] - .filter(isNonEmptyString) - .join(' · '); -} - -function formatAssessmentFindingEvidence( - finding: PatrolAssessmentAssistantFindingInput, -): string | undefined { - const title = normalizeText(finding.title) || 'Patrol finding'; - const resource = getAssessmentFindingResource(finding); - const pendingApproval = normalizeApprovalBriefing(finding.pendingApproval); - const severityStatus = [ - formatIdentifierLabel(finding.severity), - formatIdentifierLabel(finding.status), - ] - .filter(isNonEmptyString) - .join(' '); - const approvalPosture = [ - pendingApproval.id - ? pendingApproval.status === 'pending' - ? 'live approval pending' - : formatIdentifierLabel(pendingApproval.status) - : undefined, - formatAssessmentApprovalRiskLabel(pendingApproval.riskLevel), - ] - .filter(isNonEmptyString) - .join(' '); - const resourceLabel = formatAssessmentResourceLabel(resource); - return [title, resourceLabel, severityStatus, approvalPosture] - .filter(isNonEmptyString) - .join(' · '); -} - -function formatAssessmentRecentChangeEvidence(change: ResourceChange): string | undefined { - const summary = formatAssessmentRecentChangeSummary(change); - const resource = normalizeText(change.resourceId); - const observedAt = normalizeText(change.observedAt); - return [ - summary, - resource ? `resource ${resource}` : undefined, - formatAssessmentRecentChangeRelatedResources(change), - observedAt ? `observed ${observedAt}` : undefined, - ] - .filter(isNonEmptyString) - .join(' · '); -} - -function formatAssessmentCorrelationEvidence(correlation: ResourceCorrelation): string | undefined { - const source = formatAssessmentCorrelationEndpoint(correlation, 'source'); - const target = formatAssessmentCorrelationEndpoint(correlation, 'target'); - const pattern = truncateContextText(formatResourceCorrelationPattern(correlation), 120); - return [ - source && target ? `${source} to ${target}` : source || target, - pattern ? `pattern ${pattern}` : undefined, - formatResourceCorrelationSummary(correlation), - ] - .filter(isNonEmptyString) - .join(' · '); -} - -function formatAssessmentRecentChangeContextLine(change: ResourceChange, index: number): string { - const parts = [ - formatAssessmentRecentChangeSummary(change), - normalizeText(change.id) ? `change ${normalizeText(change.id)}` : undefined, - normalizeText(change.resourceId) ? `resource ${normalizeText(change.resourceId)}` : undefined, - normalizeText(change.observedAt) ? `observed ${normalizeText(change.observedAt)}` : undefined, - normalizeText(change.occurredAt) ? `occurred ${normalizeText(change.occurredAt)}` : undefined, - normalizeText(change.sourceType) - ? `source ${formatIdentifierLabel(change.sourceType)?.toLowerCase()}` - : undefined, - normalizeText(change.sourceAdapter) - ? `adapter ${formatIdentifierLabel(change.sourceAdapter)}` - : undefined, - normalizeText(change.confidence) - ? `${formatIdentifierLabel(change.confidence)?.toLowerCase()} confidence` - : undefined, - normalizeText(change.actor) ? `actor ${truncateContextText(change.actor, 80)}` : undefined, - formatAssessmentRecentChangeRelatedResources(change), - ].filter(isNonEmptyString); - - return `Recent Change ${index}: ${parts.join(' · ')}`; -} - -function formatAssessmentRecentChangeRelatedResources(change: ResourceChange): string | undefined { - const labels: string[] = []; - for (const relatedResource of change.relatedResources ?? []) { - const label = truncateContextText( - relatedResource, - MAX_ASSESSMENT_RELATED_RESOURCE_LABEL_LENGTH, - ); - if (label && !labels.includes(label)) { - labels.push(label); - } - } - if (labels.length === 0) return undefined; - - const visibleLabels = labels.slice(0, MAX_ASSESSMENT_RELATED_RESOURCE_LABELS); - const omittedCount = labels.length - visibleLabels.length; - return `related resources ${formatAssessmentRelatedResourceList(visibleLabels, omittedCount)}`; -} - -function formatAssessmentRelatedResourceList(labels: string[], omittedCount: number): string { - if (labels.length === 1) { - return omittedCount > 0 ? `${labels[0]}, and ${omittedCount} more` : labels[0]; - } - if (labels.length === 2 && omittedCount === 0) { - return `${labels[0]} and ${labels[1]}`; - } - const visibleList = labels.join(', '); - return omittedCount > 0 ? `${visibleList}, and ${omittedCount} more` : visibleList; -} - -function formatAssessmentCorrelationContextLine( - correlation: ResourceCorrelation, - index: number, -): string { - const source = formatAssessmentCorrelationEndpoint(correlation, 'source'); - const target = formatAssessmentCorrelationEndpoint(correlation, 'target'); - const parts = [ - source && target ? `${source} to ${target}` : source || target, - normalizeText(correlation.event_pattern) - ? `pattern ${truncateContextText(formatResourceCorrelationPattern(correlation), 140)}` - : undefined, - formatResourceCorrelationSummary(correlation), - normalizeText(correlation.last_seen) - ? `last seen ${normalizeText(correlation.last_seen)}` - : undefined, - normalizeText(correlation.description) - ? `description ${truncateContextText(correlation.description, 180)}` - : undefined, - ].filter(isNonEmptyString); - - return `Correlation ${index}: ${parts.join(' · ')}`; -} - -function formatAssessmentRecentChangeSummary(change: ResourceChange): string { - // An alert that closed without recovering (moved to its Pulse agent) is - // told in the alert engine's words, never as "Alert resolved". - if (getResourceChangeAlertResolution(change)) { - return truncateContextText(formatResourceChangeHeadline(change), 160); - } - - const kind = formatResourceChangeKind(change.kind); - if (isCommandBearingResourceChange(change)) { - return `${kind}: execution event recorded`; - } - - if ( - (change.kind === 'state_transition' || change.kind === 'restart') && - normalizeText(change.from) && - normalizeText(change.to) - ) { - return `${kind}: ${truncateContextText(change.from, 80)} to ${truncateContextText( - change.to, - 80, - )}`; - } - - if (normalizeText(change.reason)) { - return `${kind}: ${truncateContextText(change.reason, 160)}`; - } - - return `${kind}: ${normalizeText(change.resourceId) || normalizeText(change.id) || 'resource'}`; -} - -function isCommandBearingResourceChange(change: ResourceChange): boolean { - return change.kind === 'command_executed' || change.kind === 'runbook_executed'; -} - -function formatAssessmentCorrelationEndpoint( - correlation: ResourceCorrelation, - role: 'source' | 'target', -): string | undefined { - const label = normalizeText(formatResourceCorrelationEndpoint(correlation, role)); - const id = - role === 'source' ? normalizeText(correlation.source_id) : normalizeText(correlation.target_id); - const type = - role === 'source' - ? normalizeText(correlation.source_type) - : normalizeText(correlation.target_type); - const displayLabel = label || id; - if (!displayLabel) return undefined; - - const qualifiers = [ - type ? formatIdentifierLabel(type)?.toLowerCase() : undefined, - id && id !== displayLabel ? id : undefined, - ] - .filter(isNonEmptyString) - .join(' '); - return qualifiers ? `${displayLabel} (${qualifiers})` : displayLabel; -} - -function formatAssessmentFindingContextLine( - finding: PatrolAssessmentAssistantFindingInput, - index: number, -): string { - const title = truncateContextText(normalizeText(finding.title) || 'Patrol finding', 120); - const resource = getAssessmentFindingResource(finding); - const record = buildPatrolInvestigationRecordPresentation(finding.investigationRecord); - const proposedFix = record.proposedFix || normalizeProposedFixBriefing(finding.proposedFix); - const pendingApproval = normalizeApprovalBriefing(finding.pendingApproval); - const recordApprovalId = normalizeText(finding.investigationRecord?.approval_id); - const statusParts = [ - formatIdentifierLabel(finding.severity), - formatIdentifierLabel(finding.status), - formatIdentifierLabel(finding.investigationStatus), - formatIdentifierLabel(finding.investigationOutcome), - formatIdentifierLabel(finding.loopState), - ].filter(isNonEmptyString); - const raisedParts = [ - normalizeNonNegativeCount(finding.timesRaised) > 1 - ? `raised ${normalizeNonNegativeCount(finding.timesRaised)} times` - : undefined, - normalizeNonNegativeCount(finding.regressionCount) > 0 - ? `regressed ${normalizeNonNegativeCount(finding.regressionCount)} time${ - normalizeNonNegativeCount(finding.regressionCount) === 1 ? '' : 's' - }` - : undefined, - formatBriefingTimestamp(normalizeText(finding.lastRegressionAt)) - ? `last regression ${formatBriefingTimestamp(normalizeText(finding.lastRegressionAt))}` - : undefined, - ]; - const recordParts = [ - record.statusLabel, - record.outcomeLabel, - record.confidenceLabel, - record.conclusion ? `conclusion ${truncateContextText(record.conclusion, 180)}` : undefined, - record.recommendedAction - ? `recorded action note ${truncateContextText(record.recommendedAction, 180)}` - : undefined, - recordApprovalId && recordApprovalId !== pendingApproval.id - ? `approval ${recordApprovalId}` - : undefined, - ...formatAssessmentPendingApprovalContextParts(pendingApproval), - proposedFix?.description - ? `action artifact ${truncateContextText(proposedFix.description, 160)}` - : undefined, - proposedFix?.commandSummary, - proposedFix?.destructive ? 'destructive action artifact' : undefined, - record.error ? `investigation error ${truncateContextText(record.error, 160)}` : undefined, - ]; - - const parts = [ - `${title} on ${formatAssessmentResourceLabel(resource) || 'affected resource'}`, - normalizeText(finding.id) ? `finding ${normalizeText(finding.id)}` : undefined, - statusParts.length > 0 ? statusParts.join(' ') : undefined, - normalizeText(finding.description) - ? `description ${truncateContextText(finding.description, 180)}` - : undefined, - normalizeText(finding.detectedAt) ? `detected ${normalizeText(finding.detectedAt)}` : undefined, - normalizeText(finding.lastSeenAt) - ? `last seen ${normalizeText(finding.lastSeenAt)}` - : undefined, - ...raisedParts, - ...recordParts, - ].filter(isNonEmptyString); - - return `Finding ${index}: ${parts.join(' · ')}`; -} - function buildPatrolAssistantFindingModelContext( input: PatrolAssistantFindingHandoffInput, ): string { @@ -1918,57 +900,6 @@ function buildPatrolAssistantFindingModelContext( .join('\n'); } -function formatAssessmentPendingApprovalContextParts( - approval: Required, -): string[] { - const parts: string[] = []; - if (!approval.id) return parts; - - parts.push(`approval ${approval.id}`); - if (approval.status === 'pending') { - parts.push('live approval pending'); - } else { - const statusLabel = formatIdentifierLabel(approval.status)?.toLowerCase(); - if (statusLabel) { - parts.push(`${statusLabel} approval`); - } - } - const riskLabel = formatIdentifierLabel(approval.riskLevel)?.toLowerCase(); - if (riskLabel) { - parts.push(`${riskLabel} risk`); - } - if (approval.targetName) { - parts.push(`approval target ${truncateContextText(approval.targetName, 120)}`); - } - if (approval.expiresAt) { - parts.push(`expires ${approval.expiresAt}`); - } - if (approval.requestedAt) { - parts.push(`requested ${approval.requestedAt}`); - } - if (approval.actionRequestedBy) { - parts.push(`requested by ${approval.actionRequestedBy}`); - } - return parts; -} - -function formatAssessmentApprovalRiskLabel(riskLevel?: string | null): string | undefined { - const riskLabel = formatIdentifierLabel(riskLevel); - return riskLabel ? `${riskLabel} risk` : undefined; -} - -function getAssessmentFindingResource( - finding: PatrolAssessmentAssistantFindingInput, -): AIChatHandoffResource { - const subject = finding.investigationRecord?.subject; - return { - id: normalizeText(finding.resourceId) || normalizeText(subject?.resource_id), - name: normalizeText(finding.resourceName) || normalizeText(subject?.resource_name) || undefined, - type: normalizeText(finding.resourceType) || normalizeText(subject?.resource_type) || undefined, - node: normalizeText(subject?.node) || undefined, - }; -} - function formatAssessmentResourceLabel(resource: AIChatHandoffResource): string | undefined { const name = normalizeText(resource.name); const id = normalizeText(resource.id); diff --git a/scripts/release_control/ai_runtime_docs_policy_test.py b/scripts/release_control/ai_runtime_docs_policy_test.py index 9634648f5c..e994e8c316 100644 --- a/scripts/release_control/ai_runtime_docs_policy_test.py +++ b/scripts/release_control/ai_runtime_docs_policy_test.py @@ -121,6 +121,11 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase): self.assertNotIn("Pulse Assistant should be replaced by", normalized_content) self.assertNotIn("MCP replaces Pulse Assistant", normalized_content) self.assertNotIn("Assistant can be removed", normalized_content) + # The Patrol summary card and its supporting-context section are gone, + # so the docs point at where check history actually lives. + self.assertIn("Activity > Finding options and history", normalized_content) + self.assertNotIn("legacy operational score remain", normalized_content) + self.assertNotIn("collapsed supporting-context section", normalized_content) self.assertIn("- **Anthropic** (API key)", content) self.assertIn("Anthropic OAuth is not a supported runtime authentication method", normalized_content) self.assertIn("does not make Anthropic configured", normalized_content) @@ -304,7 +309,7 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase): self.assertIn("## Patrol Modes", control_doc) self.assertIn("Patrol mode sets how far Pulse can go", control_doc) self.assertIn("**UI:** Patrol → Patrol mode", control_doc) - self.assertIn("The API keeps the autonomy_level field name for compatibility.", control_doc) + self.assertIn("The API retains `autonomy_level` for compatibility", control_doc) self.assertNotIn("Patrol Control Level", control_doc) self.assertNotIn("## Patrol Control Levels", control_doc) self.assertNotIn("Patrol control is configured on the **Patrol** page.", control_doc)