mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Keep Patrol action history consistent with recorded outcomes
Refresh durable investigation lifecycle from authoritative actions while preserving completed evidence. Keep resolved history reviewable and label recorded plan facts separately from action outcomes. Follow all resource pages during qualification and record live approval, rejection and storage semantic-review results. Integrate current main and preserve its alert ordering correction.
This commit is contained in:
@@ -2159,3 +2159,99 @@ reporting agent is untouched. Any agent enrollment must use the canonical scoped
|
||||
installation flow, preserve explicit identity and revoke temporary execution
|
||||
access after qualification. Detection success does not satisfy this prerequisite
|
||||
or the remaining backup and independent-environment cases.
|
||||
|
||||
|
||||
## Installed agent and governed action qualification, 2026-09-06
|
||||
|
||||
The maintainer explicitly approved a temporary update and scoped command token
|
||||
for Tower's separate development agent, followed by restoration. The installed
|
||||
agent artifact was `ae2ed8b97709ec6e71af979c293ca9d3634662767ec1b59629baf4933c90cf5d`.
|
||||
Both host and Docker modules reported running, and the command connection
|
||||
registered the same agent identity. The production agent retained PID 752388.
|
||||
The tests used Ask first with manual triggers. Scheduled Patrol ended paused in
|
||||
Watch only. No autonomous-mode qualification is claimed.
|
||||
|
||||
The first installed storage attempt, `q-20260906-192435-f0d7eebf`, failed before
|
||||
inference. Inspection established a qualification-client pagination defect:
|
||||
`/api/resources?limit=1000` returned a maximum of 100 records from an inventory
|
||||
of 104, leaving the exact worker on page two. This was not an absence of normal
|
||||
collection. The client now follows the API pages and rejects partial results
|
||||
when a later page fails. A regression finds an unhealthy resource beyond the
|
||||
first 100, and the complete qualification package passes. Fault oracles and
|
||||
score thresholds were not weakened. The corrected runner hash is
|
||||
`a27f9ab0bf4786b670db3e8a989e974c8c43c5084d9524474ee694735d2e7df9`.
|
||||
|
||||
| Case / run | Automated result | Reviewed outcome |
|
||||
|---|---|---|
|
||||
| Approved restart, `q-20260906-193013-12d25545` | Pass | Correct unhealthy container, exact finding/investigation/resource and plan-hash binding, explicit approval before execution, completed restart, independent healthy/running readback and lifecycle verification. Detection 8.893s, fault-to-remediation phase total 78.490s. |
|
||||
| Rejected restart, `q-20260906-193207-7095dad5` | Pass | Exact plan rejected, no restart, independent unchanged unhealthy fault until teardown. Detection 10.838s, fault-to-decision phase total 37.795s. |
|
||||
| Storage, `q-20260906-193613-556ef23d` | Pass from existing scorecard | **Fails semantic diagnosis review.** Collection converged and logs exposed ENOSPC, but the model incorrectly asserted that Tower was out of disk space and implicated its array. Only an 8 MiB container tmpfs was exhausted. |
|
||||
|
||||
The approved action is `act_dcc3b52e5451810e49466daf9a6fccb0`, linked to finding
|
||||
`566515f71129ce73` and investigation `aae05717-c9b0-4aac-8439-ba78f46c28e9`.
|
||||
The rejected action is `act_ee0b736f0430e472e896a456ba3cb6eb`, linked to finding
|
||||
`a17940552206e1ca` and investigation `955f4f47-0b6f-404b-b513-70a1d226f111`.
|
||||
Each case passed independent teardown, second-cleanup no-op and restored
|
||||
inventory. Per-case detection estimates were $0.012123, $0.012283 and $0.014915.
|
||||
These exclude investigation calls and are not provider-account spend.
|
||||
|
||||
Storage remains unqualified. The model had the collected tmpfs mount and its
|
||||
configured size. Its canonical-resource log call failed, the fallback host and
|
||||
container log call succeeded, and its `df -h` command required approval. It then
|
||||
promoted unrelated host/array warnings into a definite capacity diagnosis.
|
||||
The scorecard's required terms and narrow forbidden phrases missed that false
|
||||
claim. Its raw pass is retained as evidence of a qualification limitation, not
|
||||
accepted as product success. The next storage slice needs canonical, authorized
|
||||
filesystem-capacity evidence and explicit semantic review against the bounded
|
||||
fault. Do not permit arbitrary commands merely to make that case pass, add a
|
||||
benchmark-specific diagnosis rule, or treat identifier/phrase matches as proof
|
||||
of causal correctness. Backup coverage and independent Pro environments remain
|
||||
unqualified as well.
|
||||
|
||||
Real outcome review exposed stale durable records: the finding and investigation
|
||||
could say `fix_verified` while the embedded product record still said
|
||||
`fix_queued`. Action reconciliation now refreshes that record through the same
|
||||
canonical builder used at investigation completion, preserving original model
|
||||
prose, evidence and retained rollback. Read-time hydration repairs existing
|
||||
records even when the top-level outcome already matches. Unchanged hydration
|
||||
must not republish state or repeat outcome notifications. Resolved findings keep
|
||||
their exact action-history link, and Assistant handoff preserves resolved status.
|
||||
Investigation completion replaces an earlier partial action projection with its
|
||||
final evidence. Subsequent action transitions preserve that completed evidence,
|
||||
including impact and confidence that the current finding may no longer retain.
|
||||
An intermediate proof build exposed that loss of retained impact. The regression
|
||||
now preserves it, while already absent historical fields remain unassessed.
|
||||
Final runtime and browser verification of these corrections is recorded below.
|
||||
|
||||
After qualification, both original development binaries were restored separately
|
||||
because they differed: runtime `e5a2b60e52e35c37f68daa348c642757b56a64b40ca1d72f4db843ce69eb5db4`,
|
||||
persistent `73c224dfd750c41b2cbd883c3ce7e352071862bc6a60e59fe6ed4dd3de312bc6`.
|
||||
The original protected token was restored, temporary issued tokens were revoked
|
||||
and checked absent, temporary backups were removed after comparison, and no
|
||||
owned fault containers remained. The restored v6.2.0-rc.8 development agent
|
||||
reported fresh telemetry. Its original token lacks command scope, so its command
|
||||
connection is again absent by design. Production PID 752388 remained unchanged.
|
||||
|
||||
Final action-history proof uses Pro Darwin arm64 binary
|
||||
`859d5d2de84cfd2264caa7dbcf5f080e3b1d06b5876df2c81dc0e00872f5e779`.
|
||||
Worker proof passes the API action reconciliation selection and investigation,
|
||||
record, rollback and early-projection completion regressions. The full API suite
|
||||
passed in 310.353s before the final evidence-preservation refinement, followed
|
||||
by the final targeted regressions. The three affected action component suites
|
||||
pass 30 tests. The complete qualification package and pagination regressions
|
||||
also pass. The exact final staged hook gates landing.
|
||||
|
||||
Playwright exercises `/patrol` Activity/All and both exact `/actions?action=...`
|
||||
links above at 1440, 900 and 390 by 1000. Final-content checks cover resolved
|
||||
record retention, outcome agreement, safety disclosure, completed/rejected
|
||||
headers, planning-time copy, absent settled execution controls, independent
|
||||
verification, policy/evidence/delivery disclosures, keyboard toggles, Escape,
|
||||
close controls, deep-link reload, scroll fit and retained review focus. Actual
|
||||
pixels were inspected at desktop, intermediate and phone sizes. Assistant
|
||||
handoff opens the same finding with completed/rejected context and read-only
|
||||
control. Provider readiness POST was deliberately blocked during that rendering
|
||||
proof and no prompt was sent. Earlier browser attempts encountered an
|
||||
intermittent bootstrap connection screen. The complete final matrix passed
|
||||
after removing redundant immediate navigations from the proof driver, without
|
||||
claiming a bootstrap fix. Source bindings are in
|
||||
`frontend-modern/browser-verification.json`.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,24 @@ that same result. Successful reads retain their content and execution provenance
|
||||
|
||||
## Purpose
|
||||
|
||||
Action reconciliation refreshes the durable product investigation record from
|
||||
the authoritative session/action even when the finding outcome already matches.
|
||||
The same builder owns initial completion and later refresh. Completion replaces
|
||||
an early action projection with the final investigation evidence. Later action
|
||||
refresh preserves original prose, impact, confidence, evidence and rollback. Unchanged hydration is a no-op, and a
|
||||
record-only repair does not repeat outcome notifications. Resolved findings
|
||||
retain the canonical action-history link and resolved status in Assistant context.
|
||||
Live approved and rejected recovery cases pass, but storage's lexical scorecard
|
||||
pass fails semantic review because an exhausted container tmpfs was incorrectly
|
||||
attributed to host capacity. Storage and broader product qualification stay open.
|
||||
|
||||
The live qualification client follows the canonical resource API's pagination.
|
||||
The API caps each page at 100, so a larger requested limit cannot establish a
|
||||
complete inventory. A later-page failure returns an error rather than partial
|
||||
inventory. Regression proof covers an unhealthy resource beyond the first 100
|
||||
and failure while reading a later page. This changes collection coverage, not
|
||||
fault oracles, model context policy or outcome scoring.
|
||||
|
||||
Stored chat and product history share the result-bearing `TranscriptToolCall`
|
||||
contract. API adapters preserve observed output and the explicit success/error
|
||||
bit. Only provider-request projections remove those display fields. A failed
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Overview delivery diagnoses use latest-started refresh ownership. Older bulk
|
||||
responses cannot overwrite newer card notification status, and an empty active
|
||||
alert set invalidates outstanding reads. Disposal also prevents updates. Failed
|
||||
refreshes retain the existing snapshot; this ordering repair does not add a
|
||||
freshness indicator or establish recipient receipt. Verify response overlap in
|
||||
`OverviewTab.deliverystatus.test.tsx`, empty-set invalidation in
|
||||
`useAlertOverviewState.test.tsx`, and rendered ordering at three widths using
|
||||
`scripts/check-alert-diagnosis-ordering.mjs`.
|
||||
|
||||
Delivery-attempt and held-event reads in Destinations use latest-started
|
||||
refresh ownership. A delayed mount response must not overwrite evidence from
|
||||
configuration Retry or a queue-action refresh, including a newer unavailable
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Action reconciliation refreshes the durable product investigation record from
|
||||
the authoritative session/action even when the finding outcome already matches.
|
||||
The same builder owns initial completion and later refresh. Original prose,
|
||||
evidence and retained rollback survive. Unchanged hydration is a no-op, and a
|
||||
record-only repair does not repeat outcome notifications. Resolved findings
|
||||
retain the canonical action-history link and resolved status in Assistant context.
|
||||
Live approved and rejected recovery cases pass, but storage's lexical scorecard
|
||||
pass fails semantic review because an exhausted container tmpfs was incorrectly
|
||||
attributed to host capacity. Storage and broader product qualification stay open.
|
||||
|
||||
Product history retains the stored result-bearing `TranscriptToolCall` contract,
|
||||
including observed output and an optional success bit. A false bit is retained,
|
||||
and an absent historical result bit stays absent. Provider requests use the
|
||||
|
||||
@@ -28,6 +28,15 @@ across the first reload, including default-hidden migrations. Final-source
|
||||
browser proof covers Docker host details, Machines column selection and tooltip
|
||||
focus/dismissal at desktop, intermediate and narrow widths.
|
||||
|
||||
Overview delivery diagnoses use latest-started refresh ownership. Older bulk
|
||||
responses cannot overwrite newer card notification status, and an empty active
|
||||
alert set invalidates outstanding reads. Disposal also prevents updates. Failed
|
||||
refreshes retain the existing snapshot; this ordering repair does not add a
|
||||
freshness indicator or establish recipient receipt. Verify response overlap in
|
||||
`OverviewTab.deliverystatus.test.tsx`, empty-set invalidation in
|
||||
`useAlertOverviewState.test.tsx`, and rendered ordering at three widths using
|
||||
`scripts/check-alert-diagnosis-ordering.mjs`.
|
||||
|
||||
The Destinations delivery-log state primitive assigns a generation to each
|
||||
refresh and rejects stale completions before updating rows, unavailable state
|
||||
or loading state. Held-event reads share that generation without blocking the
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Action reconciliation refreshes the durable product investigation record from
|
||||
the authoritative session/action even when the finding outcome already matches.
|
||||
The same builder owns initial completion and later refresh. Original prose,
|
||||
evidence and retained rollback survive. Unchanged hydration is a no-op, and a
|
||||
record-only repair does not repeat outcome notifications. Resolved findings
|
||||
retain the canonical action-history link and resolved status in Assistant context.
|
||||
Live approved and rejected recovery cases pass, but storage's lexical scorecard
|
||||
pass fails semantic review because an exhausted container tmpfs was incorrectly
|
||||
attributed to host capacity. Storage and broader product qualification stay open.
|
||||
|
||||
Pausing the Patrol schedule does not disable investigation history review.
|
||||
The finding review control owns one detail target and regains keyboard focus on
|
||||
close. Investigation conclusions use sanitized Markdown, deduplicate identical
|
||||
|
||||
@@ -2210,6 +2210,7 @@
|
||||
],
|
||||
"exact_files": [
|
||||
"internal/api/ai_handler_test.go",
|
||||
"internal/api/ai_handlers_investigation_additional_test.go",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"internal/api/ai_handlers_test.go",
|
||||
@@ -3247,6 +3248,7 @@
|
||||
"exact_files": [
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"internal/api/action_runner_credentials_test.go",
|
||||
"internal/api/ai_handlers_investigation_additional_test.go",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"internal/api/alerting/external_probe_notifications_test.go",
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Action review distinguishes the recorded plan from live or executed facts. The
|
||||
shared decision packet labels its state and expiry as planning-time evidence,
|
||||
including when opened from a resolved Patrol finding. Potential blast radius
|
||||
does not claim every related resource was affected. Actual execution and
|
||||
verification remain in the recorded outcome section. The review header uses the
|
||||
shared action-state presentation, so rejected actions remain identifiable even
|
||||
without an execution receipt. The rejected-state regression and live
|
||||
completed/rejected deep-link browser proof cover these historical journeys.
|
||||
|
||||
Docker container writable/root layer bytes describe image composition, not
|
||||
used/total filesystem capacity, and cannot populate `ResourceMetrics.Disk`.
|
||||
Optional valid block-I/O rate pointers preserve measured zero. Missing, negative
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "b0b39f00dc6685ad9ed63e8a6e91b954338073e4",
|
||||
"verified_at": "2026-09-06T18:44:39.842657Z",
|
||||
"base_sha": "57ead19484c49d003287309d0734875405da9040",
|
||||
"verified_at": "2026-09-06T20:11:45.553223Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/patrol.ts",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx",
|
||||
"frontend-modern/src/components/Login.tsx",
|
||||
"frontend-modern/src/components/patrol/InvestigationMessages.tsx",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
|
||||
"frontend-modern/src/useAppRuntimeState.ts",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts",
|
||||
"frontend-modern/src/utils/localStorage.ts"
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx",
|
||||
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/patrol.ts": "3e5bec5d29d9218450c3fb5b531fd17652759c325e049bde497e3fb96544a622",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx": "1063492593361f29a88109641d76fc2492027cd23b470452dfa4f437fa325700",
|
||||
"frontend-modern/src/components/Login.tsx": "621f0b97d775aacaa521a3c72ed02db3fde5ad3eda06d55950a32107723eb44c",
|
||||
"frontend-modern/src/components/patrol/InvestigationMessages.tsx": "4518f4755ff3d7dcdd0d8030372011e3481726db27ef695a9afc1d4ea6af3976",
|
||||
"frontend-modern/src/components/patrol/InvestigationSection.tsx": "32669694f6dc959c3c3684f17a4ecb4c3d9411f7fc2ae20f1192d68b1d87e52b",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "1e52d00e61fb6affb9365cee74eb6820135d1d6d8a20161d02499ab3d6084da5",
|
||||
"frontend-modern/src/useAppRuntimeState.ts": "5a1d343d92c441e1302dab129be7256dd9a287c6a60fb0e17c6f9fa13fe20900",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts": "c716d8b61501acec2bfde253febd4ddce6e9dfee9eef77a3230a2512326dfd7c",
|
||||
"frontend-modern/src/utils/localStorage.ts": "182ed45685228781fd32725db50611b1115f9ee4fb1af23aeedcf41ca707bdbc"
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx": "f506a26757b4c0ea3adf3f77af10214bfd31578b7122d3904a9b3a7272d1e146",
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx": "6a18d67d5d3d0a8335589bdf199c340eb4775aea3442dc094971d7f924a0dcc5",
|
||||
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx": "2f1fd68ec333e7f9e8792d74ba8d7e6a2e95755c82b9c7121d847469a31f931b",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "49e12cfd44686bd657ddddfb167c5956ec693b6f5d45d9d43c1e3a2e858a6c7f",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts": "64d0b891e7ad228e8590da859dc25e825b6164c8cf76a01983a219d6cd079b23"
|
||||
},
|
||||
"backend_content_sha256": {
|
||||
"internal/agentcapabilities/transcript.go": "356c4ca201470407988ff9b2c1fb848619ed38e9d8db844e7390adce0f93ec19",
|
||||
@@ -32,18 +24,22 @@
|
||||
"internal/api/chat_service_adapter.go": "6d0ab14456b1901c5020a408de95796ece1aa8d1057dd58737ea2637777d6ccb",
|
||||
"internal/ai/cost/pricing.go": "7b64bc881311ee0a7c1c8a319fcd162974f5a307ced1679e614ff20ac1ac532c",
|
||||
"internal/ai/tools/tools_query.go": "3e074b204a8c8c4f8b66eaf147c269a2e57739bfc69d03ea36ee8c47cf8b908a",
|
||||
"internal/ai/tools/tools_propose.go": "43d720c78a010b72f53f7e4e9e1b7b2a763e7f1ac555edb921b7cdca3e82fd51"
|
||||
"internal/ai/tools/tools_propose.go": "43d720c78a010b72f53f7e4e9e1b7b2a763e7f1ac555edb921b7cdca3e82fd51",
|
||||
"internal/ai/patrol_findings.go": "d5eeb386f025cca338ac328b1d4ec7a2bf51150023454e2b61670f196fcf0356",
|
||||
"internal/api/ai_handlers.go": "f8c9b24dc684346da4bddab066540fd43ca4999f5fd379c4dc34b5293f78394c",
|
||||
"internal/api/patrol_action_reconciliation.go": "bc5da1a8050b94271dcdc88841a0ce3329e1773bd01c8746068391a71740ffa2"
|
||||
},
|
||||
"enterprise_base_sha": "3d9f4e3051d38027355a2a1f36b8c7f672a09b65",
|
||||
"enterprise_content_sha256": {
|
||||
"internal/investigation/orchestrator.go": "d56fd512dc47f8a2453559e89863da5d24dffc0a5977abb8f1ea7a82655cd9e8"
|
||||
},
|
||||
"binary_sha256": "24de8c9ea0020067d298c489f0d99a272b5ec4b00afab7a40dff55aabb244061",
|
||||
"binary_sha256": "859d5d2de84cfd2264caa7dbcf5f080e3b1d06b5876df2c81dc0e00872f5e779",
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/settings/pulse-intelligence/patrol",
|
||||
"/settings/pulse-intelligence/provider",
|
||||
"/"
|
||||
"/qualification (isolated Overview component on :5199)",
|
||||
"/patrol (Activity, All history)",
|
||||
"/actions?action=act_dcc3b52e5451810e49466daf9a6fccb0",
|
||||
"/actions?action=act_ee0b736f0430e472e896a456ba3cb6eb",
|
||||
"Pulse Assistant contextual panel from resolved Patrol findings"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -60,15 +56,14 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Live successful dependency diagnosis renders sanitized headings once, with distinct summaries retained. Paused history remains interactive and narrow finding controls wrap. Resolution does not infer manual review from an attention outcome.",
|
||||
"Live broker refusal appears once alongside original diagnosis, needs_attention and no action. Historical successful query and failed ACTION_NOT_ALLOWED/NO_AGENT results retain actual input, output and status.",
|
||||
"Controlled GET transcript fixture retains output with absent success status without inventing completion or failure. This proves rendering only.",
|
||||
"Selected exact Gemini 3.8 Flash route, known $0.75/$3.75 rates, readiness for Watch only/Ask first and unassessed autonomous modes survive reload. Provider card read only. Browser interception blocks automatic preflight POST, so provider health was verified separately through the live API.",
|
||||
"Incoming unchanged demo login source rechecked at 1440/390: loading, automatic demo payload, authenticated shell, logout/reload suppression, manual re-entry and rejected-login fallback. GET demo-presentation fixture with authorized local login exchange, no runtime demo configuration mutation."
|
||||
"Incoming merged alert delivery diagnosis ordering: hold older request, add alert to start newer request, render current notifications-disabled state, release older ready response, verify current state and both cards remain. Existing Patrol and login proof is retained in the prior committed receipt and runtime source is unchanged.",
|
||||
"Real persisted approved/verified and rejected findings remain reviewable after resolution. Durable investigation outcome agrees with authoritative action, with original prose retained. No stale Fix Queued status. Exact action links remain available.",
|
||||
"Completed and Rejected action headers, State when planned and Plan expiry copy, inert settled action controls, independently verified recovery and explicit unavailable rollback. Policy, evidence and delivery disclosures expand and collapse.",
|
||||
"Assistant handoff shows the exact finding with completed/rejected action context and Chat: Read-only. Browser provider readiness POST is deliberately blocked, so its visible route error is a rendering check and does not retest the provider. No prompt is submitted."
|
||||
],
|
||||
"interactions": [
|
||||
"Activity, Finding options and history, All while Patrol paused. Review via hover/focus and Enter, unique aria-controls target, close returns focus, reopen, nested investigation disclosure open/closed, keyboard Enter/Space tool expansion/collapse and deep output scrolling. Actual desktop/intermediate/mobile pixels inspected after final source changes.",
|
||||
"Patrol model settings and rates/readiness scrolled into view and reloaded at all three widths. No model setting change, action execution or approval request during browser proof.",
|
||||
"Private receipts and interaction matrix under tmp/patrol-gemini-38/browser. Real run q-20260906-182952-6acfb739 remains failed: broker refused disconnected command agent, exact diagnosis retained and cleanup passed. No successful approval or recovery claim."
|
||||
"scripts/check-alert-diagnosis-ordering.mjs passed at 1440, 900 and 390 by 1000. Actual pixels inspected at desktop and narrow sizes. This is scripted component evidence, not proof of installed delivery or recipient receipt. Screenshots in /tmp/pulse-alert-diagnosis-ordering/.",
|
||||
"Final Pro binary and final frontend content exercised in Playwright at 1440, 900 and 390 by 1000. Keyboard open/review, safety disclosure, exact action navigation, direct deep-link reload, policy/evidence/delivery disclosure keyboard toggles, Escape and close-button dismissal, review focus return where retained, Assistant open/close, scrolling and page overflow checks. Desktop/intermediate/phone pixels inspected including deepest evidence and Assistant overlay.",
|
||||
"Private artifacts: tmp/patrol-gemini-38/live-action-browser/. Final complete matrix passed after removing redundant back-to-back full-page navigations from the proof driver. Earlier proof attempts hit an intermittent bootstrap connection screen. No bootstrap fix or general availability claim is made. API writes blocked except login."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2070,18 +2070,18 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
{/* Inline Approval Section (replaces manual approval JSX) */}
|
||||
<Show
|
||||
when={
|
||||
finding.status === 'active' &&
|
||||
(finding.investigationOutcome === 'fix_queued' ||
|
||||
finding.investigationOutcome === 'fix_executed' ||
|
||||
finding.investigationOutcome === 'fix_failed' ||
|
||||
finding.investigationOutcome === 'fix_rejected' ||
|
||||
finding.investigationOutcome === 'fix_verified' ||
|
||||
finding.investigationOutcome === 'fix_verification_failed' ||
|
||||
finding.investigationOutcome === 'fix_verification_unknown')
|
||||
finding.investigationOutcome === 'fix_queued' ||
|
||||
finding.investigationOutcome === 'fix_executed' ||
|
||||
finding.investigationOutcome === 'fix_failed' ||
|
||||
finding.investigationOutcome === 'fix_rejected' ||
|
||||
finding.investigationOutcome === 'fix_verified' ||
|
||||
finding.investigationOutcome === 'fix_verification_failed' ||
|
||||
finding.investigationOutcome === 'fix_verification_unknown'
|
||||
}
|
||||
>
|
||||
<ApprovalSection
|
||||
findingId={finding.id}
|
||||
findingStatus={finding.status}
|
||||
investigationOutcome={finding.investigationOutcome}
|
||||
findingTitle={getFindingTitlePresentation(finding).label}
|
||||
resourceName={finding.resourceName}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ActionAuditState, PatrolActionReference } from '@/types/actionAudi
|
||||
|
||||
interface ApprovalSectionProps {
|
||||
findingId: string;
|
||||
findingStatus?: string;
|
||||
investigationOutcome?: string;
|
||||
findingTitle?: string;
|
||||
resourceName?: string;
|
||||
@@ -127,7 +128,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
current?.plan.message ||
|
||||
investigation()?.summary ||
|
||||
'Review the current Patrol finding and its governed action state.',
|
||||
findingStatus: 'active',
|
||||
findingStatus: props.findingStatus ?? 'active',
|
||||
investigationOutcome: props.investigationOutcome,
|
||||
loopState: props.investigationOutcome || current?.state,
|
||||
resourceId: props.resourceId || current?.resource_id,
|
||||
|
||||
@@ -67,13 +67,17 @@ describe('ApprovalSection typed action handoff', () => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
const renderSection = (investigationOutcome: string) =>
|
||||
const renderSection = (investigationOutcome: string, findingStatus = 'active') =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<ApprovalSection findingId="finding-1" investigationOutcome={investigationOutcome} />
|
||||
<ApprovalSection
|
||||
findingId="finding-1"
|
||||
findingStatus={findingStatus}
|
||||
investigationOutcome={investigationOutcome}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
@@ -104,13 +108,20 @@ describe('ApprovalSection typed action handoff', () => {
|
||||
it('routes terminal action history to the exact recorded outcome', async () => {
|
||||
getInvestigationMock.mockResolvedValue(investigation(actionReference('completed')));
|
||||
|
||||
renderSection('fix_verified');
|
||||
renderSection('fix_verified', 'resolved');
|
||||
|
||||
expect(await screen.findByRole('link', { name: /view outcome in actions/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/actions?action=act-1',
|
||||
);
|
||||
expect(screen.getByText('Outcome verified')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i }));
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
handoffContext: expect.stringContaining('Resolved'),
|
||||
autonomousMode: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps missing plan identity visible while leaving replan guidance to Actions', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const ActionDecisionPacket: Component<{
|
||||
class="rounded-lg border border-border bg-surface p-4"
|
||||
>
|
||||
<h3 id="action-intent-heading" class="text-sm font-semibold text-base-content">
|
||||
What will happen
|
||||
Action plan
|
||||
</h3>
|
||||
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
@@ -69,7 +69,7 @@ export const ActionDecisionPacket: Component<{
|
||||
</Show>
|
||||
<Show when={props.audit.plan.preflight?.currentState}>
|
||||
<div>
|
||||
<dt class="text-muted">Current state</dt>
|
||||
<dt class="text-muted">State when planned</dt>
|
||||
<dd>{props.audit.plan.preflight?.currentState}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -80,7 +80,7 @@ export const ActionDecisionPacket: Component<{
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<dt class="text-muted">Approval expires</dt>
|
||||
<dt class="text-muted">Plan expiry</dt>
|
||||
<dd>{expiry()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -90,7 +90,7 @@ export const ActionDecisionPacket: Component<{
|
||||
</dl>
|
||||
<Show when={blastRadiusEntries().length > 0}>
|
||||
<div class="mt-3">
|
||||
<div class="text-sm text-muted">Also affected</div>
|
||||
<div class="text-sm text-muted">Potentially affected</div>
|
||||
<ul class="mt-1 list-disc pl-5 text-sm">
|
||||
<For each={blastRadiusEntries()}>
|
||||
{(entry) => (
|
||||
|
||||
@@ -4,12 +4,14 @@ import ArrowUpRightIcon from 'lucide-solid/icons/arrow-up-right';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
|
||||
import type { ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { ActionDecisionPacket } from './ActionDecisionPacket';
|
||||
import {
|
||||
formatActionName,
|
||||
getActionInboxStatePresentation,
|
||||
getActionOriginDestination,
|
||||
getActionResourcePresentation,
|
||||
} from './actionPresentation';
|
||||
@@ -244,9 +246,14 @@ export const ActionReviewDialog: Component<{
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Governed action review
|
||||
</p>
|
||||
<h2 id="action-review-title" class="mt-1 text-xl font-semibold">
|
||||
{formatActionName(record().request.capabilityName)}
|
||||
</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h2 id="action-review-title" class="text-xl font-semibold">
|
||||
{formatActionName(record().request.capabilityName)}
|
||||
</h2>
|
||||
<MetadataBadge tone={getActionInboxStatePresentation(record().state).tone}>
|
||||
{getActionInboxStatePresentation(record().state).label}
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{resource().label}
|
||||
<Show when={resource().detail}> · {resource().detail}</Show>
|
||||
|
||||
@@ -115,6 +115,16 @@ const detail = (audit: ActionAuditRecord): ActionDetailResponse => ({
|
||||
});
|
||||
|
||||
describe('ActionReviewDialog trust gates', () => {
|
||||
it('keeps a rejected action outcome visible without offering execution', () => {
|
||||
const audit = makeAudit('resolved', '2026-07-12T00:10:00Z');
|
||||
audit.state = 'rejected';
|
||||
render(() => <ActionReviewDialog detail={detail(audit)} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Rejected', { exact: true })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /approve|run|refresh plan/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links a trusted Patrol action back to its exact operational record', () => {
|
||||
const audit = makeAudit('resolved', '2099-01-01T00:00:00Z');
|
||||
audit.origin = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
@@ -124,6 +125,27 @@ describe('OverviewTab delivery status line', () => {
|
||||
if (state.reason === 'cooldown') expect(screen.getByText(/next eligible/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores an older diagnosis response after the active alert set changes', async () => {
|
||||
let finishOlder!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
getDeliveryDiagnoses.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishOlder = resolve;
|
||||
}),
|
||||
);
|
||||
getDeliveryDiagnoses.mockResolvedValueOnce([
|
||||
makeDiagnosis('a1', { status: 'suppressed', reason: 'notifications_disabled' }),
|
||||
]);
|
||||
const [alerts, setAlerts] = createSignal<Record<string, Alert>>({ a1: makeAlert('a1') });
|
||||
render(() => <OverviewTab {...defaultProps()} activeAlerts={alerts()} />);
|
||||
await waitFor(() => expect(getDeliveryDiagnoses).toHaveBeenCalledTimes(1));
|
||||
setAlerts({ a1: makeAlert('a1'), a2: makeAlert('a2') });
|
||||
await waitFor(() => expect(screen.getByText('Notifications are turned off')).toBeTruthy());
|
||||
finishOlder([makeDiagnosis('a1', { lastNotified: '2026-08-26T10:15:00Z' })]);
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText(/^Dispatch requested /)).toBeNull();
|
||||
expect(screen.getByText('Notifications are turned off')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders no delivery line when the diagnosis fetch fails', async () => {
|
||||
const activeAlerts: Record<string, Alert> = { a1: makeAlert('a1') };
|
||||
getDeliveryDiagnoses.mockRejectedValue(new Error('boom'));
|
||||
|
||||
@@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
|
||||
import { useAlertOverviewState } from '../useAlertOverviewState';
|
||||
|
||||
vi.mock('@/api/alerts', () => ({
|
||||
AlertsAPI: {
|
||||
getDeliveryDiagnoses: vi.fn(),
|
||||
acknowledge: vi.fn(),
|
||||
bulkAcknowledge: vi.fn(),
|
||||
unacknowledge: vi.fn(),
|
||||
@@ -47,6 +48,7 @@ describe('useAlertOverviewState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-22T12:00:00Z'));
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(AlertsAPI.acknowledge).mockReset();
|
||||
vi.mocked(AlertsAPI.unacknowledge).mockReset();
|
||||
vi.mocked(AlertsAPI.bulkAcknowledge).mockReset();
|
||||
@@ -58,6 +60,86 @@ describe('useAlertOverviewState', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('invalidates pending diagnosis reads when the active set becomes empty', async () => {
|
||||
let finish!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const [activeAlerts, setActiveAlerts] = createSignal<Record<string, Alert>>({
|
||||
a1: makeAlert('a1', new Date().toISOString()),
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useAlertOverviewState({
|
||||
activeAlerts,
|
||||
overrides: () => [],
|
||||
showAcknowledged: () => true,
|
||||
updateAlert: vi.fn(),
|
||||
}),
|
||||
);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
setActiveAlerts({});
|
||||
finish([{ alertIdentifier: 'a1', reason: 'ready' } as AlertDeliveryDiagnosis]);
|
||||
await Promise.resolve();
|
||||
expect(result.deliveryDiagnoses()).toEqual({});
|
||||
});
|
||||
|
||||
it('does not accept an older success when a newer periodic refresh fails', async () => {
|
||||
let finishOlder!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
const retained = {
|
||||
alertIdentifier: 'a1',
|
||||
reason: 'notifications_disabled',
|
||||
} as AlertDeliveryDiagnosis;
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses)
|
||||
.mockResolvedValueOnce([retained])
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishOlder = resolve;
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('refresh unavailable'));
|
||||
const { result } = renderHook(() =>
|
||||
useAlertOverviewState({
|
||||
activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }),
|
||||
overrides: () => [],
|
||||
showAcknowledged: () => true,
|
||||
updateAlert: vi.fn(),
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(result.deliveryDiagnoses()).toEqual({ a1: retained });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(3);
|
||||
finishOlder([{ alertIdentifier: 'a1', reason: 'ready' } as AlertDeliveryDiagnosis]);
|
||||
await Promise.resolve();
|
||||
expect(result.deliveryDiagnoses()).toEqual({ a1: retained });
|
||||
});
|
||||
|
||||
it('ignores pending responses and stops periodic reads after disposal', async () => {
|
||||
let finish!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, cleanup } = renderHook(() =>
|
||||
useAlertOverviewState({
|
||||
activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }),
|
||||
overrides: () => [],
|
||||
showAcknowledged: () => true,
|
||||
updateAlert: vi.fn(),
|
||||
}),
|
||||
);
|
||||
cleanup();
|
||||
finish([{ alertIdentifier: 'a1', reason: 'ready' } as AlertDeliveryDiagnosis]);
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(result.deliveryDiagnoses()).toEqual({});
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('owns overview stats, filtering, and acknowledge flows outside the tab shell', async () => {
|
||||
const now = Date.now();
|
||||
const [activeAlerts] = createSignal<Record<string, Alert>>({
|
||||
|
||||
@@ -68,17 +68,21 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
Record<string, AlertDeliveryDiagnosis>
|
||||
>({});
|
||||
let diagnosisStateDisposed = false;
|
||||
let diagnosisRequestVersion = 0;
|
||||
onCleanup(() => {
|
||||
diagnosisStateDisposed = true;
|
||||
});
|
||||
const refreshDeliveryDiagnoses = async () => {
|
||||
// A slower previous refresh must not replace a newer notification state.
|
||||
// Increment even for an empty alert set to invalidate outstanding requests.
|
||||
const requestVersion = ++diagnosisRequestVersion;
|
||||
if (activeAlerts().length === 0) {
|
||||
setDeliveryDiagnoses({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await AlertsAPI.getDeliveryDiagnoses();
|
||||
if (diagnosisStateDisposed) return;
|
||||
if (diagnosisStateDisposed || requestVersion !== diagnosisRequestVersion) return;
|
||||
const next: Record<string, AlertDeliveryDiagnosis> = {};
|
||||
for (const diagnosis of list) {
|
||||
next[diagnosis.alertIdentifier || diagnosis.alertId] = diagnosis;
|
||||
|
||||
@@ -204,3 +204,25 @@ func TestFindingsStore_UpdateInvestigationRecord(t *testing.T) {
|
||||
t.Fatal("expected false for missing finding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolInvestigationCompletionReplacesEarlyActionProjection(t *testing.T) {
|
||||
store := NewFindingsStore()
|
||||
store.Add(&Finding{ID: "finding-1", ResourceID: "vm-100", Title: "High CPU", DetectedAt: time.Now()})
|
||||
patrol := &PatrolService{findings: store}
|
||||
session := &InvestigationSession{ID: "investigation-1", FindingID: "finding-1", Summary: "Investigation in progress"}
|
||||
if !patrol.RefreshFindingInvestigationRecord("finding-1", session) {
|
||||
t.Fatal("early action projection was not recorded")
|
||||
}
|
||||
completed := time.Now()
|
||||
session.Status = aicontracts.InvestigationStatusCompleted
|
||||
session.CompletedAt = &completed
|
||||
session.Summary = "Final diagnosis supported by the completed reads"
|
||||
session.EvidenceIDs = []string{"final-evidence"}
|
||||
if !patrol.storeFindingInvestigationRecord("finding-1", session, false) {
|
||||
t.Fatal("completed investigation did not replace its early projection")
|
||||
}
|
||||
record := store.Get("finding-1").InvestigationRecord
|
||||
if record.Conclusion != session.Summary || record.CompletedAt == nil || len(record.Evidence) != 1 || record.Evidence[0].ID != "final-evidence" {
|
||||
t.Fatalf("completed evidence was lost: %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -1791,23 +1792,9 @@ func (p *PatrolService) maybeInvestigateFinding(f *Finding) bool {
|
||||
if orchestrator != nil {
|
||||
latestInvestigation = orchestrator.GetInvestigationByFinding(latest.ID)
|
||||
}
|
||||
if record := BuildFindingInvestigationRecord(latest, latestInvestigation); record != nil {
|
||||
// When a remediation plan exists for this finding, lift its
|
||||
// per-step rollback strings into record.Rollback so the
|
||||
// operator-facing investigation surface answers
|
||||
// "what's the undo for the proposed fix?" at the record root
|
||||
// rather than only in nested per-step payload.
|
||||
if engine := p.remediationEngine; engine != nil {
|
||||
if plan := engine.GetPlanForFinding(latest.ID); plan != nil {
|
||||
record.Rollback = AggregatePlanRollbackSteps(plan)
|
||||
}
|
||||
}
|
||||
if p.findings.UpdateInvestigationRecord(latest.ID, record) {
|
||||
if refreshed := p.findings.Get(latest.ID); refreshed != nil {
|
||||
latest = refreshed
|
||||
} else {
|
||||
latest.InvestigationRecord = record
|
||||
}
|
||||
if p.storeFindingInvestigationRecord(latest.ID, latestInvestigation, false) {
|
||||
if refreshed := p.findings.Get(latest.ID); refreshed != nil {
|
||||
latest = refreshed
|
||||
}
|
||||
}
|
||||
if pushUnified != nil {
|
||||
@@ -1850,11 +1837,50 @@ func (p *PatrolService) maybeInvestigateFinding(f *Finding) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PublishFindingLifecycleUpdate projects a reconciled action outcome to the
|
||||
// unified finding owner and, for terminal execution outcomes, to mobile push.
|
||||
// It is called only after the finding store changed, so duplicate action
|
||||
// callbacks and read-time hydration do not emit duplicate notifications.
|
||||
func (p *PatrolService) PublishFindingLifecycleUpdate(findingID string) {
|
||||
// RefreshFindingInvestigationRecord preserves the latest investigation and
|
||||
// reconciled action in the durable record shared by product surfaces.
|
||||
func (p *PatrolService) RefreshFindingInvestigationRecord(findingID string, session *InvestigationSession) bool {
|
||||
return p.storeFindingInvestigationRecord(findingID, session, true)
|
||||
}
|
||||
|
||||
func (p *PatrolService) storeFindingInvestigationRecord(findingID string, session *InvestigationSession, preserveEvidence bool) bool {
|
||||
if p == nil || p.findings == nil {
|
||||
return false
|
||||
}
|
||||
finding := p.findings.Get(findingID)
|
||||
if finding == nil {
|
||||
return false
|
||||
}
|
||||
record := BuildFindingInvestigationRecord(finding, session)
|
||||
// Later action transitions update lifecycle facts, not the evidence and
|
||||
// diagnosis captured when this investigation completed. The current finding
|
||||
// may no longer retain all of that original context after restart.
|
||||
if previous := finding.InvestigationRecord; preserveEvidence && previous != nil && previous.ID == record.ID {
|
||||
retained := previous.NormalizeCollections()
|
||||
retained.Status = record.Status
|
||||
retained.Outcome = record.Outcome
|
||||
retained.Action = record.Action
|
||||
retained.Verification = record.Verification
|
||||
record = &retained
|
||||
} else {
|
||||
p.mu.RLock()
|
||||
engine := p.remediationEngine
|
||||
p.mu.RUnlock()
|
||||
if engine != nil {
|
||||
if plan := engine.GetPlanForFinding(findingID); plan != nil {
|
||||
record.Rollback = AggregatePlanRollbackSteps(plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
if reflect.DeepEqual(finding.InvestigationRecord, record) {
|
||||
return false
|
||||
}
|
||||
return p.findings.UpdateInvestigationRecord(findingID, record)
|
||||
}
|
||||
|
||||
// PublishFindingLifecycleUpdate projects reconciled records to the unified
|
||||
// finding owner. Repairing a stale record alone must not repeat outcome pushes.
|
||||
func (p *PatrolService) PublishFindingLifecycleUpdate(findingID string, outcomeChanged bool) {
|
||||
if p == nil || p.findings == nil {
|
||||
return
|
||||
}
|
||||
@@ -1873,7 +1899,7 @@ func (p *PatrolService) PublishFindingLifecycleUpdate(findingID string) {
|
||||
if finding.ResolvedAt != nil && resolveUnified != nil {
|
||||
resolveUnified(finding.ID)
|
||||
}
|
||||
if pushNotify == nil {
|
||||
if pushNotify == nil || !outcomeChanged {
|
||||
return
|
||||
}
|
||||
switch InvestigationOutcome(finding.InvestigationOutcome) {
|
||||
|
||||
@@ -534,11 +534,25 @@ func validatePatrolRoute(expected string, settings AISettings, status PatrolStat
|
||||
}
|
||||
|
||||
func (c *PulseClient) Resources(ctx context.Context) ([]Resource, error) {
|
||||
var response struct {
|
||||
Data []Resource `json:"data"`
|
||||
var resources []Resource
|
||||
for page := 1; ; page++ {
|
||||
var response struct {
|
||||
Data []Resource `json:"data"`
|
||||
Meta struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
// The resource API caps each page at 100 regardless of the requested
|
||||
// limit. Follow its pagination so later resources can converge too.
|
||||
path := fmt.Sprintf("/api/resources?limit=100&page=%d", page)
|
||||
if err := c.request(ctx, http.MethodGet, path, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, response.Data...)
|
||||
if page >= response.Meta.TotalPages {
|
||||
return resources, nil
|
||||
}
|
||||
}
|
||||
err := c.request(ctx, http.MethodGet, "/api/resources?limit=1000", nil, &response)
|
||||
return response.Data, err
|
||||
}
|
||||
|
||||
func (c *PulseClient) WaitForResources(ctx context.Context, names map[string]string, timeout, poll time.Duration) (map[string]Resource, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +14,62 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWaitForResourcesMatchingIncludesLaterPages(t *testing.T) {
|
||||
var pages []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/resources" || r.URL.Query().Get("limit") != "100" {
|
||||
t.Errorf("unexpected resource request: %s", r.URL)
|
||||
}
|
||||
page := r.URL.Query().Get("page")
|
||||
pages = append(pages, page)
|
||||
resources := make([]Resource, 0, 100)
|
||||
switch page {
|
||||
case "1":
|
||||
for i := 0; i < 100; i++ {
|
||||
resources = append(resources, Resource{ID: fmt.Sprintf("control-%d", i), Name: fmt.Sprintf("control-%d", i)})
|
||||
}
|
||||
case "2":
|
||||
resources = append(resources, Resource{ID: "storage", Name: "worker", Docker: &DockerResource{Health: "unhealthy"}})
|
||||
default:
|
||||
t.Errorf("unexpected page: %q", page)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": resources, "meta": map[string]int{"totalPages": 2}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resources, err := client.WaitForResourcesMatching(context.Background(), map[string]string{"service": "worker"}, time.Second, time.Millisecond, func(resources map[string]Resource) error {
|
||||
if resources["service"].Docker.Health != "unhealthy" {
|
||||
return errors.New("fault not collected")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || resources["service"].ID != "storage" || strings.Join(pages, ",") != "1,2" {
|
||||
t.Fatalf("resources=%v pages=%v err=%v", resources, pages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesDoesNotReturnPartialInventoryWhenLaterPageFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("page") == "1" {
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"first"}],"meta":{"totalPages":2}}`))
|
||||
return
|
||||
}
|
||||
http.Error(w, "resource inventory unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resources, err := client.Resources(context.Background())
|
||||
if err == nil || resources != nil {
|
||||
t.Fatalf("incomplete inventory returned: resources=%v err=%v", resources, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerAndWaitAssociatesExactNewScopedRun(t *testing.T) {
|
||||
var triggered atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7741,6 +7741,10 @@ func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http.
|
||||
|
||||
// updateFindingOutcome updates the investigation outcome on a finding
|
||||
func (h *AISettingsHandler) updateFindingOutcome(ctx context.Context, orgID, findingID, outcome string) {
|
||||
h.updateFindingInvestigationOutcome(ctx, orgID, findingID, outcome, nil)
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) updateFindingInvestigationOutcome(ctx context.Context, orgID, findingID, outcome string, investigation *ai.InvestigationSession) {
|
||||
// Get AI service for this org
|
||||
svc := h.GetAIService(ctx)
|
||||
if svc == nil {
|
||||
@@ -7759,15 +7763,20 @@ func (h *AISettingsHandler) updateFindingOutcome(ctx context.Context, orgID, fin
|
||||
log.Warn().Str("orgID", orgID).Msg("Findings store not available for finding update")
|
||||
return
|
||||
}
|
||||
if existing := findingsStore.Get(findingID); existing != nil && existing.InvestigationOutcome == outcome {
|
||||
existing := findingsStore.Get(findingID)
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !findingsStore.UpdateInvestigationOutcome(findingID, outcome) {
|
||||
outcomeChanged := existing.InvestigationOutcome != outcome
|
||||
if outcomeChanged && !findingsStore.UpdateInvestigationOutcome(findingID, outcome) {
|
||||
log.Warn().Str("findingID", findingID).Msg("Finding not found for outcome update")
|
||||
return
|
||||
}
|
||||
patrol.PublishFindingLifecycleUpdate(findingID)
|
||||
recordChanged := investigation != nil && patrol.RefreshFindingInvestigationRecord(findingID, investigation)
|
||||
if !outcomeChanged && !recordChanged {
|
||||
return
|
||||
}
|
||||
patrol.PublishFindingLifecycleUpdate(findingID, outcomeChanged)
|
||||
|
||||
log.Info().Str("findingID", findingID).Str("outcome", outcome).Msg("Updated finding investigation outcome")
|
||||
}
|
||||
|
||||
@@ -258,6 +258,59 @@ func TestPatrolActionReconciliationCannotRegressFromOutOfOrderCallback(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolActionHydrationRepairsDurableFindingRecordWithoutRewritingEvidence(t *testing.T) {
|
||||
investigations := newTestInvestigationStore()
|
||||
investigation := investigations.Create("finding-1", "session-1")
|
||||
investigation.Status = aicontracts.InvestigationStatusCompleted
|
||||
investigation.Outcome = aicontracts.OutcomeFixQueued
|
||||
investigation.Summary = "Cause uncertain. Restart proposed for review."
|
||||
investigation.EvidenceIDs = []string{"observed-health"}
|
||||
investigations.Update(investigation)
|
||||
svc := ai.NewService(nil, nil)
|
||||
svc.SetStateProvider(&MockStateProvider{})
|
||||
patrol := svc.GetPatrolService()
|
||||
findings := patrol.GetFindings()
|
||||
findings.Add(&ai.Finding{ID: "finding-1", ResourceID: "vm:42", Title: "Unhealthy service", Severity: ai.FindingSeverityWarning,
|
||||
InvestigationStatus: string(investigation.Status), InvestigationOutcome: string(aicontracts.OutcomeFixRejected)})
|
||||
// Reproduce the persisted mismatch after an outcome already reconciled.
|
||||
record := ai.BuildFindingInvestigationRecord(findings.Get("finding-1"), investigation)
|
||||
record.Rollback = []string{"Retained rollback evidence"}
|
||||
record.Impact = "Original service impact absent from the later finding projection."
|
||||
findings.UpdateInvestigationRecord("finding-1", record)
|
||||
audits := unifiedresources.NewMemoryStore()
|
||||
audit := unifiedresources.ActionAuditRecord{
|
||||
ID: "act-1", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), State: unifiedresources.ActionStateRejected,
|
||||
Request: unifiedresources.ActionRequest{RequestID: "proposal-1", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
|
||||
Plan: unifiedresources.ActionPlan{ActionID: "act-1", RequestID: "proposal-1", Allowed: true},
|
||||
Origin: &unifiedresources.ActionOrigin{Surface: patrolActionOriginSurface, FindingID: "finding-1", InvestigationID: investigation.ID, ProposalID: "proposal-1"},
|
||||
}
|
||||
if _, _, err := audits.CreateActionAudit(audit, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := &AISettingsHandler{defaultAIService: svc,
|
||||
investigationStores: map[string]aicontracts.InvestigationStore{"default": investigations},
|
||||
resourceStoreProvider: func(string) (unifiedresources.ResourceStore, error) { return audits, nil },
|
||||
}
|
||||
var published []*ai.Finding
|
||||
patrol.SetUnifiedFindingCallback(func(f *ai.Finding) bool { published = append(published, f); return true })
|
||||
published = nil // Ignore the initial synchronization when registering the callback.
|
||||
handler.hydratePatrolInvestigationAction("default", investigation)
|
||||
got := findings.Get("finding-1").InvestigationRecord
|
||||
if got.Outcome != aicontracts.OutcomeFixRejected || got.Action == nil || got.Action.State != "rejected" {
|
||||
t.Fatalf("durable record did not reconcile: %#v", got)
|
||||
}
|
||||
if got.Conclusion != investigation.Summary || got.Impact != record.Impact || got.Confidence != record.Confidence || !reflect.DeepEqual(got.Rollback, record.Rollback) || !reflect.DeepEqual(got.Evidence, record.Evidence) {
|
||||
t.Fatalf("retained investigation evidence changed: %#v", got)
|
||||
}
|
||||
if len(got.Verification) != 1 || len(published) != 1 || published[0].InvestigationRecord.Outcome != aicontracts.OutcomeFixRejected {
|
||||
t.Fatalf("reconciled record was not published: record=%#v published=%#v", got, published)
|
||||
}
|
||||
handler.hydratePatrolInvestigationAction("default", investigation)
|
||||
if len(published) != 1 {
|
||||
t.Fatal("duplicate hydration republished unchanged record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolActionReconciliationHydratesTerminalAuditAfterRestart(t *testing.T) {
|
||||
investigations := newTestInvestigationStore()
|
||||
investigation := investigations.Create("finding-1", "session-1")
|
||||
|
||||
@@ -105,7 +105,7 @@ func (h *AISettingsHandler) applyPatrolActionAudit(orgID string, audit unifiedre
|
||||
store.Update(investigation)
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID)
|
||||
h.updateFindingOutcome(ctx, orgID, origin.FindingID, string(outcome))
|
||||
h.updateFindingInvestigationOutcome(ctx, orgID, origin.FindingID, string(outcome), investigation)
|
||||
return
|
||||
}
|
||||
if changed {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Isolated real-browser component qualification; no installed backend or delivery claim.
|
||||
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
|
||||
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
const root = resolve("frontend-modern");
|
||||
process.chdir(root);
|
||||
const fixture = `
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { AlertsAPI } from '/src/api/alerts';
|
||||
import { NotificationsAPI } from '/src/api/notifications';
|
||||
import { OverviewTab } from '/src/features/alerts/OverviewTab';
|
||||
import '/src/index.css';
|
||||
let finishOlder;
|
||||
let requests = 0;
|
||||
AlertsAPI.getDeliveryDiagnoses = () => {
|
||||
requests++;
|
||||
if (requests === 1) return new Promise(resolve => { finishOlder = resolve; });
|
||||
return Promise.resolve([{alertIdentifier:'a1', alertId:'a1', status:'suppressed',
|
||||
reason:'notifications_disabled', message:'Notifications disabled by current configuration'}]);
|
||||
};
|
||||
window.finishOlder = () => finishOlder([{alertIdentifier:'a1', alertId:'a1',
|
||||
status:'would_send',reason:'ready',lastNotified:'2026-08-26T10:15:00Z'}]);
|
||||
window.requestCount = () => requests;
|
||||
AlertsAPI.getEvents = async () => [];
|
||||
NotificationsAPI.getHealth = async () => ({queue:{status:'healthy'}});
|
||||
const alert = id => ({id,resourceId:id,resourceName:'VM '+id,type:'cpu',level:'warning',
|
||||
message:'High CPU on '+id,startTime:new Date().toISOString(),acknowledged:false,node:'node1'});
|
||||
function Fixture() {
|
||||
const [alerts, setAlerts] = createSignal({a1:alert('a1')});
|
||||
return <main class="p-4"><button onClick={()=>setAlerts({a1:alert('a1'),a2:alert('a2')})}>Add alert</button>
|
||||
<OverviewTab overrides={[]} activeAlerts={alerts()}
|
||||
updateAlert={()=>{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true}
|
||||
setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/></main>; }
|
||||
render(()=><Router><Route path="/qualification" component={Fixture}/></Router>,document.getElementById('root'));
|
||||
`;
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: false,
|
||||
optimizeDeps: {
|
||||
noDiscovery: true,
|
||||
entries: [],
|
||||
esbuildOptions: { target: "esnext" },
|
||||
},
|
||||
esbuild: { target: "esnext" },
|
||||
plugins: [
|
||||
solid(),
|
||||
{
|
||||
name: "dispatch-fixture",
|
||||
configureServer(s) {
|
||||
s.middlewares.use((req, res, next) => {
|
||||
if (req.url === "/qualification") {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end(
|
||||
'<div id="root"></div><script type="module" src="/dispatch-fixture.tsx"></script>',
|
||||
);
|
||||
} else next();
|
||||
});
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === "/dispatch-fixture.tsx") return id;
|
||||
},
|
||||
load(id) {
|
||||
if (id === "/dispatch-fixture.tsx") return fixture;
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: { alias: { "@": resolve(root, "src") } },
|
||||
server: { host: "127.0.0.1", port: 5199, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
mkdirSync("/tmp/pulse-alert-diagnosis-ordering", { recursive: true });
|
||||
for (const width of [1440, 900, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => {
|
||||
errors.push(e.message);
|
||||
console.error(e.message);
|
||||
});
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error(m.text());
|
||||
});
|
||||
await page.route("http://127.0.0.1:5199/api/**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.goto("http://127.0.0.1:5199/qualification");
|
||||
await page.waitForFunction(() => window.requestCount?.() === 1);
|
||||
await page.getByRole("button", { name: "Add alert", exact: true }).click();
|
||||
await page
|
||||
.getByText("Notifications are turned off", { exact: true })
|
||||
.waitFor();
|
||||
await page.evaluate(async () => {
|
||||
window.finishOlder();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.equal(
|
||||
await page
|
||||
.getByText("Notifications are turned off", { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(await page.getByText(/^Dispatch requested /).count(), 0);
|
||||
assert.equal(
|
||||
await page.getByText("High CPU on a2", { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
const label = page.getByText("Notifications are turned off", {
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(
|
||||
await label.evaluate((el) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
return [...range.getClientRects()].every(
|
||||
(b) => b.left >= 0 && b.right <= innerWidth,
|
||||
);
|
||||
}),
|
||||
true,
|
||||
"current status must fit viewport",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
await page.screenshot({
|
||||
path: "/tmp/pulse-alert-diagnosis-ordering/" + width + ".png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: "passed",
|
||||
viewports: [1440, 900, 390],
|
||||
scope:
|
||||
"Real Overview and Chromium; scripted diagnoses, not installed delivery or receipt",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -1321,6 +1321,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"exact_files": [
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"internal/api/action_runner_credentials_test.go",
|
||||
"internal/api/ai_handlers_investigation_additional_test.go",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"internal/api/alerting/external_probe_notifications_test.go",
|
||||
|
||||
Reference in New Issue
Block a user