diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index a59207d81..390b1ad7b 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -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`. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 9a6a3ab30..357352464 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10201,7 +10201,7 @@ }, { "id": "patrol-assistant-customer-outcome-qualification", - "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The primary history read was pushed as 580a246 in PR1935. The shared monitor/store identity correction now joins exact full Docker references with canonical container history through an organization-scoped history-only alias index. Original event content and approval/operator authority remain unchanged. Callback, removal/restart, replay, tenant/control and registered-tool tests, full affected packages, race checks and scoped lookup benchmarks passed. Final worker-build API proof returns the same seven retained homelab records under canonical and legacy queries, preserving the original fired/resolved events exactly. Six-case Playwright proof at desktop/intermediate/mobile widths passed. The exact thirteen-file worker hook passed and 919331d5 was pushed through PR1935. Integration with main 11a8cc2180 preserves both contract changes and passes affected backend, race, frontend and final-build browser/API proofs. The full integration hook gates the merge commit. The disconnected legacy recorder/coordinator and its cached-metrics adapter are retired. Explicit read-only archive lookup preserves recorded values and discloses legacy duration units. The old incidents API live count is explicitly unmeasured. Final archive/history/API and race checks pass, including the full API suite under the normal worker account after identifying a root-only permission-fixture failure. Final worker-build browser proof covers five archive outcomes across desktop/intermediate/mobile widths. Live canonical and legacy queries preserve the same seven original homelab records, and the API count is unmeasured. The exact staged hook gates landing. The legacy incident-memory listing still needs alias-aware canonical queries, canonical-only events, honest bounds and propagated projection-read failures. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. The earlier Claude subscription refusal is limited to the exact synthetic continuation request and has not been retried. On 2026-09-06 the maintainer authorized Gemini 3.8 Flash through OpenRouter with a US$5 one-day key. Readiness, four unhealthy-container runs, the healthy mixed control and the client-to-stopped-dependency investigation passed on Tower. Storage failed before inference because collection did not converge. Approved and rejected restart cases found the fault but did not reach either decision: the disconnected Docker command agent prevented creation of an exact action. These failures exposed lost broker errors and lost tool-result fields in the product-history adapter. Current changes preserve the failed submission separately from the model diagnosis and retain result-bearing transcript calls, with exact provider projections unchanged. Browser work corrects duplicate review IDs, unformatted duplicate summaries, paused-history access and narrow control overlap. Final rebuilt live/API/browser and worker proof gate landing. Required local work still includes installed storage collection, a command-enabled lab agent, approved/rejected action verification, storage/backup and the remaining named evidence gaps. Independent volunteered Pro environments remain a separate wider-readiness gate. Final broker-refusal repeats retain failed/needs_attention, original diagnosis and no action. Investigation history now preserves and renders result-bearing calls through the shared transcript and Assistant evidence component, including explicit failed reads and unknown historical status. Shared resolution copy no longer infers manual review from an attention/cannot-fix outcome. Desktop/intermediate/mobile browser proof binds final content. Real approved/rejected recovery, installed storage collection, backup coverage and independent environments remain open. A verified Linux agent artifact is prepared but no Tower agent credentials or production agent installation have been changed.", + "summary": "The redesign goal remains open. The plan, historical receipts and exact source bindings are in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline of 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation does not establish representative customer success. Schema17 outcome/provider/cost fields had no adoption. Shared evidence/history/risk and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains subsequent canonical history, measurement-presence, command-connectivity, tmpfs context, exact Gemini pricing, retained broker errors and result-bearing transcript corrections. Enterprise broker refusal handling merged in PR22. Real Gemini Watch, healthy-control and client-to-dependency cases passed. With explicit authority for a temporary current development agent and scoped token, approved restart q-20260906-193013-12d25545 passed exact plan/origin binding, explicit approval, execution and independent recovery. Rejected restart q-20260906-193207-7095dad5 passed exact rejection and independent non-execution. All test resources were removed, original development binaries/token restored, temporary tokens revoked, scheduled Patrol paused in monitor mode and production agent PID preserved. Storage collection failure was traced to qualification pagination beyond the API page cap of 100 and corrected with full-package regression proof. Installed storage q-20260906-193613-556ef23d passed the existing scorecard but FAILED semantic diagnosis review: the model falsely attributed an exhausted container tmpfs to Tower/array capacity despite available mount configuration. A capacity read required approval. Storage remains unqualified, and lexical/identifier scoring must not be treated as causal correctness. Next storage work needs canonical authorized filesystem evidence and independent semantic review without benchmark-specific diagnosis rules or weakened command approval. Real browser review additionally exposed an embedded investigation record left fix_queued after verified recovery and hidden action history on resolved findings. Current action reconciliation refreshes the durable record through the canonical builder, preserves prose/evidence/rollback, repairs missed transitions without duplicate publication, and retains completed action history and resolved Assistant context. Final worker regressions and source-bound runtime/browser proof pass at 1440, 900 and 390 widths, including completed/rejected history and read-only Assistant handoff. The exact staged hook and PR1935 landing gate integration. Other residuals include canonical incident-memory listing/aliases and failed-read propagation, unsupported filters, typed compatibility ID lookup, legacy direction availability, Docker-host history, responsive mount details, backup coverage and broader model qualification. Independent volunteered Pro environments remain a wider-readiness gate. Autonomous modes remain unqualified. The earlier Claude refusal was not retried.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index e6d80a622..73258760a 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index a47e21132..acb62df48 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index dce8d007d..c611f6b40 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d383c79f3..267931e28 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 4fe2cffb5..37c512dea 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b2a859541..f04bef0d7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 78dd32a8f..fe099ec83 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a4f4d323a..e5ed90cc9 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index fc49a6150..f788613e2 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -2070,18 +2070,18 @@ export const FindingsPanel: Component = (props) => { {/* Inline Approval Section (replaces manual approval JSX) */} = (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, diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 5f19b2f97..e04bef736 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -67,13 +67,17 @@ describe('ApprovalSection typed action handoff', () => { window.history.replaceState({}, '', '/'); }); - const renderSection = (investigationOutcome: string) => + const renderSection = (investigationOutcome: string, findingStatus = 'active') => render(() => ( ( - + )} /> @@ -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 () => { diff --git a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx index 55fc51c17..f1b365dc2 100644 --- a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx +++ b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx @@ -43,7 +43,7 @@ export const ActionDecisionPacket: Component<{ class="rounded-lg border border-border bg-surface p-4" >

- What will happen + Action plan

@@ -69,7 +69,7 @@ export const ActionDecisionPacket: Component<{
-
Current state
+
State when planned
{props.audit.plan.preflight?.currentState}
@@ -80,7 +80,7 @@ export const ActionDecisionPacket: Component<{
-
Approval expires
+
Plan expiry
{expiry()}
@@ -90,7 +90,7 @@ export const ActionDecisionPacket: Component<{
0}>
-
Also affected
+
Potentially affected
    {(entry) => ( diff --git a/frontend-modern/src/features/actions/ActionReviewDialog.tsx b/frontend-modern/src/features/actions/ActionReviewDialog.tsx index e08e56bd0..89bf30c11 100644 --- a/frontend-modern/src/features/actions/ActionReviewDialog.tsx +++ b/frontend-modern/src/features/actions/ActionReviewDialog.tsx @@ -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<{

    Governed action review

    -

    - {formatActionName(record().request.capabilityName)} -

    +
    +

    + {formatActionName(record().request.capabilityName)} +

    + + {getActionInboxStatePresentation(record().state).label} + +

    {resource().label} ยท {resource().detail} diff --git a/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx b/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx index f0eaf7121..823407ac6 100644 --- a/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx +++ b/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx @@ -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(() => ); + 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 = { diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx index 631ae1590..5dcaaaeb7 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx @@ -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>({ a1: makeAlert('a1') }); + render(() => ); + 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 = { a1: makeAlert('a1') }; getDeliveryDiagnoses.mockRejectedValue(new Error('boom')); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx index edc4f4569..8ee5e8e9f 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx @@ -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>({ + 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>({ diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index 650a92063..377bb479e 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -68,17 +68,21 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { Record >({}); 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 = {}; for (const diagnosis of list) { next[diagnosis.alertIdentifier || diagnosis.alertId] = diagnosis; diff --git a/internal/ai/investigation_records_test.go b/internal/ai/investigation_records_test.go index d75d4fff5..41ec67443 100644 --- a/internal/ai/investigation_records_test.go +++ b/internal/ai/investigation_records_test.go @@ -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) + } +} diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index d95c2be79..8e2a5aaa4 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -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) { diff --git a/internal/ai/qualification/client.go b/internal/ai/qualification/client.go index 136350dd1..804b873b7 100644 --- a/internal/ai/qualification/client.go +++ b/internal/ai/qualification/client.go @@ -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) { diff --git a/internal/ai/qualification/client_test.go b/internal/ai/qualification/client_test.go index dae04bc48..cc4bc104e 100644 --- a/internal/ai/qualification/client_test.go +++ b/internal/ai/qualification/client_test.go @@ -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) { diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 43d2f2678..193f9b45a 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -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") } diff --git a/internal/api/ai_handlers_investigation_additional_test.go b/internal/api/ai_handlers_investigation_additional_test.go index e0dbbc94c..bd48a1202 100644 --- a/internal/api/ai_handlers_investigation_additional_test.go +++ b/internal/api/ai_handlers_investigation_additional_test.go @@ -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") diff --git a/internal/api/patrol_action_reconciliation.go b/internal/api/patrol_action_reconciliation.go index 4d2c2ef70..5f44c3572 100644 --- a/internal/api/patrol_action_reconciliation.go +++ b/internal/api/patrol_action_reconciliation.go @@ -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 { diff --git a/scripts/check-alert-diagnosis-ordering.mjs b/scripts/check-alert-diagnosis-ordering.mjs new file mode 100644 index 000000000..45b4e30f4 --- /dev/null +++ b/scripts/check-alert-diagnosis-ordering.mjs @@ -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

    +{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true} +setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/>
    ; } +render(()=>,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( + '
    ', + ); + } 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(); +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 19a8e0c96..53c41b475 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -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",