diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 073b25988..cd83da2c8 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -5590,7 +5590,7 @@ "status": "partial", "completion": { "state": "bounded-residual", - "summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. The enterprise orchestrator consumes a structured investigation result and submits through the required action broker; PROPOSED_FIX parsing, direct command execution, command-shaped approval creation and re-arming, and the core-generated remediation-plan path are all removed, and command text in investigation prose is inert. Registry-owned invocation descriptors classify every tool by workflow kind and mutation target and enforce control level and profile at both projection and execution. Remaining post-RC hardening: policy-scoped autonomy presets (per tenant/resource/action/window), host-update and storage-pressure workflows as the second and third complete verticals, richer dry-run providers, multi-actor approvals, and a real-lab detect-plan-execute-verify-rollback journey proof.", + "summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. Action continuity is audit-authoritative: callbacks trigger org-scoped re-reads, investigation reads recover missed transitions by trusted origin, desktop and mobile decisions use the canonical /api/actions routes, terminal verification maps honestly onto Patrol findings and push outcomes, and legacy command-shaped history remains inert. The enterprise orchestrator consumes the exact capability catalog and preserves independent runtime/proposal failures; a code-level detect-plan-approve-execute-verify-reconcile journey proves one execution and durable verified completion. Remaining post-RC hardening: policy-scoped autonomy presets (per tenant/resource/action/window), host-update and storage-pressure workflows as the second and third complete verticals, richer dry-run providers, multi-actor approvals, and a real-lab detect-plan-execute-verify-rollback journey proof.", "tracking": [ { "kind": "lane-followup", @@ -5679,6 +5679,16 @@ "path": "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/patrol/ApprovalSection.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/types/actionAudit.ts", @@ -5729,6 +5739,16 @@ "path": "internal/api/contract_test.go", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/api/patrol_action_broker_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/patrol_action_reconciliation.go", + "kind": "file" + }, { "repo": "pulse", "path": "internal/unifiedresources/actions.go", @@ -5748,6 +5768,21 @@ "repo": "pulse", "path": "internal/unifiedresources/store_test.go", "kind": "file" + }, + { + "repo": "pulse-enterprise", + "path": "internal/investigation/orchestrator_test.go", + "kind": "file" + }, + { + "repo": "pulse-mobile", + "path": "src/api/__tests__/approvals.test.ts", + "kind": "file" + }, + { + "repo": "pulse-mobile", + "path": "src/api/approvals.ts", + "kind": "file" } ] }, @@ -7669,7 +7704,21 @@ ] } ], - "work_claims": [], + "work_claims": [ + { + "id": "codex-lane-l1", + "agent_id": "codex", + "summary": "Prepare and publish the v6.0.6-rc.1 exact-SHA release candidate", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-07-10T18:14:04Z", + "heartbeat_at": "2026-07-10T18:14:04Z", + "expires_at": "2026-07-10T20:14:04Z", + "work_item": { + "kind": "lane", + "id": "L1" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e7ca55b64..91625049e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -363,6 +363,12 @@ same API-owned lifecycle; agent lifecycle surfaces must not consume Patrol-origin action audits as an agent command grant or lifecycle execution shortcut, and must not subscribe lifecycle side effects to the API-owned org-scoped action-transition hook. The investigation +continuity reconciler in `internal/api/patrol_action_reconciliation.go` is +also API-owned: callbacks only wake an authoritative action-audit re-read, +and missed callbacks recover during investigation reads. Agent lifecycle +surfaces may observe the resulting audit/lifecycle evidence but must not use +callback payloads as command grants or maintain a parallel action state. +The investigation orchestrator adapter wired here exposes only investigation-specific execution and listing with no autonomy or command-execution surface, so agent lifecycle surfaces gain no command-dispatch path through Patrol diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 95ae58b91..3325bb91d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -5644,3 +5644,21 @@ via `Service.CostStore()`. `ExecutePatrolStream` deliberately does NOT record here — its caller (`patrol_ai.go`) records via its own helper, so cost is never double-counted on the patrol-via-chat path. + +Patrol action continuity is audit-authoritative after proposal submission. +Investigation execution exposes run failure and proposal failure as independent +typed channels; a proposal-only failure may become a needs-attention outcome, +but a simultaneous provider/runtime failure must remain a failed investigation. +Before the model runs, the enterprise orchestrator receives the exact broker +capability catalog, including approval floor, parameter types, enums, patterns, +and sensitivity, and must explicitly forbid proposal guessing when catalog +lookup fails or returns no capabilities. Once `ActionBroker.Submit` succeeds, +action-transition callbacks are wakeups only: `internal/api/patrol_action_reconciliation.go` +re-reads the authoritative action audit, projects its current `ActionReference` +onto both the investigation and finding, and maps terminal verification onto +`fix_verified`, `fix_verification_failed`, or `fix_verification_unknown`. +Investigation reads perform the same hydration by action id or trusted origin, +so a missed callback cannot strand Patrol on stale approval state. Finding +lifecycle publication is idempotent and emits unified lifecycle updates plus +honest terminal push outcomes without turning unverified execution into an +all-clear. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 292265f91..5dfaeabff 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3695,6 +3695,14 @@ request. Agents must accept these alongside any capability's declared codes; the manifest deliberately does not duplicate them on every entry. +Authenticated support endpoints that are not agent capabilities may emit +shared, internal-only failure codes. The pending action queue uses +`action_queue_unavailable`, `action_queue_query_failed`, and +`action_queue_encode_failed`. These values are canonical +`agentcapabilities` constants so relay, mobile, and desktop clients see one +closed vocabulary, but they are intentionally not advertised as capability +errors in the agent manifest. + The external-agent substrate is end-to-end exercised by two paired tests in `internal/api/agent_substrate_e2e_test.go`. The first test boots the full router stack and walks discovery → triage → depth: fetch @@ -7220,3 +7228,17 @@ must omit the token and let the session cookie satisfy session-only authority; when no session cookie exists, the same request may retain the API-token fallback for scoped read access. This prevents first-run token persistence from silently converting session administration calls into rejected token calls. + +The canonical pending-action surface is `GET /api/actions/pending`; decisions +and execution remain `POST /api/actions/{id}/decision` and +`POST /api/actions/{id}/execute`. All three routes are in the relay-mobile +inventory and router allowlist, and queue failures use the shared +`agentcapabilities` vocabulary. Pending rows are oldest-first and expose the +same action audit shape, including requester and origin, used by desktop and +mobile. The mobile client must approve by recording an approved decision and +then calling execute, and must reject through the decision endpoint; legacy +`/api/ai/approvals` routes are readable history only and are not a live Patrol +action path. `ActionPlanInfo` carries canonical preflight detail across the +broker boundary. Transition publication is org-scoped, persistence precedes +publication, and API reconciliation treats the callback payload only as an id +to re-read from the action lifecycle store. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 011885642..6fb480159 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4922,3 +4922,9 @@ disclosure, navigation, copy, and row-action controls must keep a 40-pixel mobile touch floor while retaining their compact desktop density. Settings navigation must own a viewport-bounded vertical scroll region so its route list does not push the active panel below the page. + +Patrol finding handoffs must derive approval posture from the canonical typed +action state and approval policy, not only from a legacy approval id. A +`pending_approval` action or any non-`none` approval floor remains explicitly +approval-bound in shared handoff metadata so Assistant, the collapsed finding +row, and the expanded action review cannot disagree. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 49c3b2d2e..679321fee 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1945,3 +1945,18 @@ either a rejected governed decision or an approved governed decision with verified outcome evidence. Patrol may use the resolved-loop count only as stricter approved-and-verified detail after the loop also has an approved governed decision and verified outcome evidence. + +The typed `ActionReference` is the primary Patrol workflow model in both the +finding row and expanded action review. Pending actions say approve or reject; +planned or approved actions say run; executing actions say running; terminal +actions present verified, failed, or honestly inconclusive verification. The +expanded `ApprovalSection` renders the canonical plan, approval floor, +preflight, safety checks, verification steps, and rollback availability, then +uses only `/api/actions` decision/execute calls. Legacy `ProposedFix` and +`ApprovalID` data may explain historical records but must never reveal a raw +command, present an approve/run control, or reconstruct an executable action; +when the typed reference is absent the UI says action details are unavailable +and offers an Assistant handoff. Collapsed-row attention state must consult the +same investigation action reference, so it cannot claim there is no approval +while the expanded panel has one. Browser proof must exercise pending, +terminal-verified, and legacy-history states rather than judging only source. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 866cd9096..56a441f4a 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1811,3 +1811,9 @@ combining it with `min-w-full`, which allows the five operational columns to compress into unreadable phone-width fragments. This is a layout-only contract: it must not add row-time measurement, duplicate tables, or viewport listeners to the Workloads rendering hot path. + +The router owns exactly one in-process Patrol action transition publisher. +Publication remains an O(1) wakeup keyed by org and action id; reconciliation +and read-time recovery use indexed action-audit lookups rather than scanning +findings or audit history. SQLite keeps dedicated state and valid-origin JSON +indexes for pending queues and missed-callback hydration. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 1dff638e1..aa1a2101b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5494,6 +5494,8 @@ "internal/config/persistence_relay_test.go", "internal/relay/client_test.go", "internal/relay/encryption_test.go", + "internal/relay/protocol_test.go", + "internal/relay/push_test.go", "pulse-mobile:src/relay/__tests__/channel.test.ts", "pulse-mobile:src/relay/__tests__/client-hardening.test.ts", "pulse-mobile:src/relay/__tests__/client.test.ts", @@ -5517,7 +5519,9 @@ "exact_files": [ "internal/relay/client_test.go", "internal/relay/config_env_test.go", - "internal/relay/encryption_test.go" + "internal/relay/encryption_test.go", + "internal/relay/protocol_test.go", + "internal/relay/push_test.go" ] }, { diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 8478e45f9..a3d576e7c 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -132,3 +132,10 @@ boundary. `POST /api/security/tokens/relay-mobile` lives in the shared auth/security router, but it must require the paid `relay` entitlement before creating a `relay:mobile:access` token so Community installs cannot bypass Relay/mobile gating through direct API calls. + +The mobile relay capability inventory and router allowlist include the +canonical pending-action read, action decision, and action execution routes. +Patrol approval pushes use `decide_action` with a canonical action id; terminal +pushes distinguish verified, unverified, verification-failed, and +execution-failed outcomes. Relay must not revive `/api/ai/approvals` as a live +mutation route or claim that executor completion alone verified the change. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index c03e627bd..2e29a08ad 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1262,3 +1262,10 @@ to security and privacy only while they preserve every governed token, scope, privacy disclosure, and API name unchanged. Responsive presentation work may add localized accessible labels, but it must not rename or weaken security-owned terms through a mobile-specific catalog variant. + +Patrol action authority remains server-derived through the canonical action +lifecycle. Relay-mobile callers may read their pending queue and submit a +decision or execution request only through the existing scoped route checks; +they cannot supply requester identity, origin, approval policy, capability +catalog entries, or verification outcome. Legacy command-shaped investigation +history is never exposed as an executable payload in desktop or mobile review. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d0b86e7ac..4bb9a107a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -333,6 +333,12 @@ boundary: storage/recovery surfaces must not treat Patrol-proposed action audits (origin surface `patrol`) as a storage-local execution channel or mint their own proposal origins, and they must not subscribe storage-local side effects to the API-owned org-scoped action-transition hook. +Patrol continuity re-reads action audits by id or trusted origin and treats +callback payloads only as wakeups. The underlying SQLite origin path persists +absent origins as NULL and guards JSON extraction/indexing with `json_valid`, +so legacy empty values cannot make audit insertion or recovery fail. Storage +and recovery consumers may observe the reconciled result but must not cache a +second authoritative Patrol action state. Storage/recovery surfaces may consume unified-resource `platformScopes` as read-only platform membership context, but they must not reinterpret runtime scope overlap as storage or recovery ownership. A Docker workload that also diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0ee366ac3..dc5482cbc 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1584,6 +1584,20 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +Action audits are the durable source of truth for Patrol action continuity. +The store exposes optional `ActionAuditOriginReader` and +`PendingActionAuditReader` capabilities; origin lookup is scoped by org and +investigation identity, while pending reads are oldest-first. SQLite persists +an absent origin as NULL, guards JSON-expression queries and indexes with +`json_valid(origin_json)`, and keeps dedicated origin/state indexes so an old +empty or malformed value cannot reject otherwise valid audit rows. Memory and +SQLite implementations preserve the same ordering and clone semantics. +Terminal audit persistence derives `VerificationOutcome` from the canonical +execution result: no verifier is unknown, a configured verifier that did not +run is unverified, a successful read-back is verified, and a failed read-back +is failed. Consumers must use that durable result rather than inferring +success from executor completion alone. + Unified-resource row actions must remain operable at phone widths without changing capability ownership. Docker and Podman lifecycle controls retain their backend-authored availability and approval semantics while using the diff --git a/frontend-modern/src/api/__tests__/actionAudit.test.ts b/frontend-modern/src/api/__tests__/actionAudit.test.ts index 1b0a43963..969051692 100644 --- a/frontend-modern/src/api/__tests__/actionAudit.test.ts +++ b/frontend-modern/src/api/__tests__/actionAudit.test.ts @@ -21,6 +21,39 @@ describe('ActionAuditAPI', () => { apiFetchJSONMock.mockReset(); }); + it('loads the canonical oldest-first pending action queue', async () => { + const response = { + actions: [ + { + id: 'action-1', + createdAt: '2026-07-10T18:00:00Z', + updatedAt: '2026-07-10T18:00:00Z', + state: 'pending_approval' as const, + request: { + requestId: 'proposal-1', + resourceId: 'docker:container:web', + capabilityName: 'restart', + reason: 'Health checks failed', + requestedBy: 'pulse_patrol', + }, + plan: { + actionId: 'action-1', + requestId: 'proposal-1', + allowed: true, + requiresApproval: true, + approvalPolicy: 'admin', + rollbackAvailable: true, + }, + }, + ], + count: 1, + }; + apiFetchJSONMock.mockResolvedValueOnce(response); + + await expect(ResourceActionsAPI.listPendingActions()).resolves.toEqual(response); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/actions/pending'); + }); + it('builds the canonical resource-scoped action audit query', async () => { apiFetchJSONMock.mockResolvedValueOnce({ audits: [ diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index b10ae6f4c..5a494d6a6 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -25,6 +25,7 @@ import type { LearningStatusResponse, ResourceIntelligence, } from '@/types/aiIntelligence'; +import type { PatrolActionReference } from '@/types/actionAudit'; export class AIAPI { private static baseUrl = '/api'; @@ -408,6 +409,7 @@ export interface InvestigationRecord { started_at: string; completed_at?: string; approval_id?: string; + action?: PatrolActionReference; error?: string; } @@ -672,11 +674,7 @@ export type ApprovalDecisionResult = // ============================================ export type InvestigationStatus = - | 'pending' - | 'running' - | 'completed' - | 'failed' - | 'needs_attention'; + 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention'; export type InvestigationOutcome = | 'resolved' | 'fix_queued' @@ -714,6 +712,7 @@ export interface InvestigationSession { evidence_ids?: string[]; proposed_fix?: ProposedFix; approval_id?: string; + action?: PatrolActionReference; summary?: string; error?: string; } diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index e5b128717..b8f5fece7 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -7,15 +7,11 @@ import { apiFetchJSON } from '@/utils/apiClient'; import { arrayOrEmpty, promoteLegacyAlertIdentifier } from './responseUtils'; import type { InvestigationRecord } from './ai'; import type { ResourceCriticality } from './resourceOperatorState'; +import type { PatrolActionReference } from '@/types/actionAudit'; export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical'; export type FindingCategory = - | 'performance' - | 'capacity' - | 'reliability' - | 'backup' - | 'security' - | 'general'; + 'performance' | 'capacity' | 'reliability' | 'backup' | 'security' | 'general'; export interface Finding { id: string; @@ -88,11 +84,7 @@ export interface CapacityForecast { } export type InvestigationStatus = - | 'pending' - | 'running' - | 'completed' - | 'failed' - | 'needs_attention'; + 'pending' | 'running' | 'completed' | 'failed' | 'needs_attention'; export type InvestigationOutcome = | 'resolved' | 'fix_queued' @@ -130,6 +122,7 @@ export interface Investigation { error?: string; proposed_fix?: ProposedFix; approval_id?: string; + action?: PatrolActionReference; } export interface ProposedFix { diff --git a/frontend-modern/src/api/resourceActions.ts b/frontend-modern/src/api/resourceActions.ts index 0c2117b30..7db0f9018 100644 --- a/frontend-modern/src/api/resourceActions.ts +++ b/frontend-modern/src/api/resourceActions.ts @@ -3,12 +3,17 @@ import type { ActionAuditPlan, ActionDecisionResponse, ActionExecutionResponse, + PendingActionsResponse, ResourceActionRequest, } from '@/types/actionAudit'; export type ActionDecisionOutcome = 'approved' | 'rejected'; export class ResourceActionsAPI { + static async listPendingActions(): Promise { + return apiFetchJSON('/api/actions/pending'); + } + static async planAction(request: ResourceActionRequest): Promise { return apiFetchJSON('/api/actions/plan', { method: 'POST', diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index fb915f944..bfe532b27 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -1,30 +1,34 @@ /** - * ApprovalSection + * Patrol action lifecycle * - * Shows when investigation has a proposed fix. - * States: Pending, Expired, Executed, Denied, Failed, Verified, VerificationFailed. + * Presents the canonical typed action referenced by an investigation. Legacy + * command-shaped proposed_fix/approval_id fields are history only and never + * become executable UI authority. */ import CheckIcon from 'lucide-solid/icons/check'; import MessageSquareIcon from 'lucide-solid/icons/message-square'; +import PlayIcon from 'lucide-solid/icons/play'; import XIcon from 'lucide-solid/icons/x'; -import { Component, For, Show, createSignal, createResource, createMemo } from 'solid-js'; -import { aiIntelligenceStore } from '@/stores/aiIntelligence'; -import { notificationStore } from '@/stores/notifications'; -import { aiChatStore } from '@/stores/aiChat'; -import { hasFeature } from '@/stores/license'; -import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; +import { Component, For, Show, createMemo, createResource, createSignal } from 'solid-js'; +import { AIAPI } from '@/api/ai'; +import { ResourceActionsAPI } from '@/api/resourceActions'; import { Button } from '@/components/shared/Button'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; -import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; import { buildPatrolAssistantFindingHandoff, - buildPatrolAssistantApprovalBriefingInput, buildPatrolAssistantProposedFixBriefingInput, - type PatrolAssistantProposedFixBriefingSource, } from '@/features/patrol/patrolInvestigationContextModel'; -import { RemediationStatus } from './RemediationStatus'; +import { aiChatStore } from '@/stores/aiChat'; +import { aiIntelligenceStore } from '@/stores/aiIntelligence'; +import { hasFeature } from '@/stores/license'; +import { notificationStore } from '@/stores/notifications'; +import type { + ActionAuditRecord, + ActionAuditState, + PatrolActionReference, +} from '@/types/actionAudit'; interface ApprovalSectionProps { findingId: string; @@ -35,95 +39,63 @@ interface ApprovalSectionProps { resourceId?: string; } -const APPROVAL_SECTION_BADGE_PROPS = { size: 'xs', shape: 'rounded' } as const; +const FIX_RELATED_OUTCOMES = new Set([ + 'fix_queued', + 'fix_executed', + 'fix_failed', + 'fix_rejected', + 'fix_verified', + 'fix_verification_failed', + 'fix_verification_unknown', +]); + +const BADGE_PROPS = { size: 'xs', shape: 'rounded' } as const; + +function actionReferenceFromAudit(audit: ActionAuditRecord): PatrolActionReference { + return { + action_id: audit.id, + proposal_id: audit.origin?.proposalId, + resource_id: audit.request.resourceId, + capability_name: audit.request.capabilityName, + state: audit.state, + plan: audit.plan, + }; +} + +function statePresentation(state: ActionAuditState): { + label: string; + tone: 'neutral' | 'info' | 'warning' | 'success' | 'danger'; +} { + switch (state) { + case 'planned': + return { label: 'Ready to run', tone: 'info' }; + case 'pending_approval': + return { label: 'Approval required', tone: 'warning' }; + case 'approved': + return { label: 'Approved', tone: 'success' }; + case 'rejected': + return { label: 'Rejected', tone: 'warning' }; + case 'executing': + return { label: 'Applying', tone: 'info' }; + case 'completed': + return { label: 'Completed', tone: 'success' }; + case 'failed': + return { label: 'Failed', tone: 'danger' }; + } +} + +function capabilityLabel(value: string): string { + return value.replace(/[._-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()); +} export const ApprovalSection: Component = (props) => { - const [actionLoading, setActionLoading] = createSignal(null); - const [executionResult, setExecutionResult] = createSignal(null); + const [busyAction, setBusyAction] = createSignal(null); + const [latestAudit, setLatestAudit] = createSignal(null); - // Find the pending approval for this finding from the store - const pendingApproval = createMemo(() => { - return ( - aiIntelligenceStore.patrolPendingApprovals.find( - (a: ApprovalRequest) => a.toolId === 'investigation_fix' && a.targetId === props.findingId, - ) ?? null - ); - }); - - const canAutoFix = createMemo(() => hasFeature('ai_autofix')); - - const proposedFixBriefing = ( - approval: ApprovalRequest | null, - fix?: PatrolAssistantProposedFixBriefingSource | null, - ) => - buildPatrolAssistantProposedFixBriefingInput( - fix || - (approval - ? { - description: approval.context, - riskLevel: approval.riskLevel, - targetHost: approval.targetName, - commandCount: approval.command ? 1 : 0, - } - : null), - ); - - const assistantHandoff = ( - approval: ApprovalRequest | null, - fix?: PatrolAssistantProposedFixBriefingSource | null, - ) => - buildPatrolAssistantFindingHandoff({ - id: props.findingId, - title: props.findingTitle || 'Patrol finding', - subject: props.resourceName || 'affected resource', - description: - approval?.context || - fix?.description || - (!approval && props.investigationOutcome === 'fix_queued' - ? 'The original approval details are no longer available. Recover or regenerate the governed approval before execution.' - : undefined), - findingStatus: 'active', - investigationOutcome: props.investigationOutcome, - loopState: props.investigationOutcome || 'awaiting_approval', - resourceId: props.resourceId, - resourceName: props.resourceName, - resourceType: props.resourceType, - pendingApproval: buildPatrolAssistantApprovalBriefingInput(approval), - proposedFix: proposedFixBriefing(approval, fix), - }); - - const handleFixWithAssistant = ( - approval: ApprovalRequest | null, - fix: PatrolAssistantProposedFixBriefingSource | null, - e: Event, - ) => { - e.stopPropagation(); - const handoff = assistantHandoff(approval, fix); - aiChatStore.open(handoff.context); - }; - - const handleDiscussQueuedFix = (e: Event) => { - e.stopPropagation(); - const handoff = assistantHandoff(null); - aiChatStore.open(handoff.context); - }; - - // Load investigation details when outcome indicates a fix was proposed/executed - const fixRelatedOutcomes = new Set([ - 'fix_queued', - 'fix_executed', - 'fix_failed', - 'fix_rejected', - 'fix_verified', - 'fix_verification_failed', - 'fix_verification_unknown', - ]); - const [investigation] = createResource( + const [investigation, { refetch }] = createResource( () => ({ findingId: props.findingId, outcome: props.investigationOutcome }), async ({ findingId, outcome }) => { - if (!outcome || !fixRelatedOutcomes.has(outcome)) { - return null; - } + if (!outcome || !FIX_RELATED_OUTCOMES.has(outcome)) return null; try { return await AIAPI.getInvestigation(findingId); } catch { @@ -132,427 +104,309 @@ export const ApprovalSection: Component = (props) => { }, ); - // Determine state - const isExpired = createMemo( - () => - !pendingApproval() && - props.investigationOutcome === 'fix_queued' && - investigation()?.proposed_fix, - ); - - const isQueuedWithoutDetails = createMemo( - () => - !pendingApproval() && - props.investigationOutcome === 'fix_queued' && - !investigation.loading && - !investigation()?.proposed_fix, - ); - - const isVerificationUnknown = createMemo( - () => props.investigationOutcome === 'fix_verification_unknown', - ); - - const isExecuted = createMemo( - () => - props.investigationOutcome === 'fix_executed' || - props.investigationOutcome === 'fix_verified' || - props.investigationOutcome === 'fix_verification_unknown' || - executionResult()?.success, - ); - - const isFailed = createMemo( - () => - props.investigationOutcome === 'fix_failed' || - props.investigationOutcome === 'fix_verification_failed' || - (executionResult() && !executionResult()!.success), - ); - const isRejected = createMemo(() => props.investigationOutcome === 'fix_rejected'); - - // Show section only when there's something to display - const shouldShow = createMemo( - () => - pendingApproval() || - isExpired() || - isQueuedWithoutDetails() || - isExecuted() || - isFailed() || - isRejected() || - executionResult(), - ); - - const handleApprove = async (approval: ApprovalRequest, e: Event) => { - e.stopPropagation(); - setActionLoading(approval.id); - try { - const result = await aiIntelligenceStore.approveInvestigationFix(approval.id); - if (result) { - setExecutionResult(result); - if (result.success) { - notificationStore.success('Fix executed successfully'); - } else { - notificationStore.error(result.error || 'Fix execution failed'); - } - } else { - notificationStore.error('Failed to execute fix — no response from server'); - } - } catch (err) { - notificationStore.error((err as Error).message || 'Failed to execute fix'); - } finally { - setActionLoading(null); + const action = createMemo(() => { + const audit = latestAudit(); + if (audit) return actionReferenceFromAudit(audit); + return investigation()?.action ?? null; + }); + const canManageAction = createMemo(() => hasFeature('ai_autofix')); + const verificationStatus = createMemo(() => { + const auditedStatus = latestAudit()?.verificationOutcome?.status; + if (auditedStatus) return auditedStatus; + switch (investigation()?.outcome) { + case 'fix_verified': + return 'verified'; + case 'fix_verification_failed': + return 'failed'; + case 'fix_verification_unknown': + return 'unverified'; + default: + return 'unknown'; } + }); + const shouldShow = createMemo(() => + Boolean(props.investigationOutcome && FIX_RELATED_OUTCOMES.has(props.investigationOutcome)), + ); + + const refreshPatrol = async () => { + await refetch(); + await aiIntelligenceStore.loadFindings(); }; - const handleDeny = async (approval: ApprovalRequest, e: Event) => { - e.stopPropagation(); - setActionLoading(approval.id); - try { - await aiIntelligenceStore.denyInvestigationFix(approval.id); - notificationStore.success('Fix rejected'); - } catch (err) { - notificationStore.error((err as Error).message || 'Failed to reject fix'); - } finally { - setActionLoading(null); - } - }; - - const handleReapprove = async (e: Event) => { - e.stopPropagation(); - setActionLoading('reapprove'); - try { - const result = await AIAPI.reapproveInvestigationFix(props.findingId); - const execResult = await aiIntelligenceStore.approveInvestigationFix(result.approval_id); - if (execResult) { - setExecutionResult(execResult); - if (execResult.success) { - notificationStore.success('Fix executed successfully'); - } else { - notificationStore.error(execResult.error || 'Fix execution failed'); - } - } else { - notificationStore.error('Failed to execute fix — no response from server'); - } - } catch (err) { - notificationStore.error((err as Error).message || 'Failed to execute fix'); - } finally { - setActionLoading(null); - } - }; - - const renderTechnicalCommandDetails = (commands: readonly string[] | string | undefined) => { - const commandList = Array.isArray(commands) - ? commands.filter(Boolean) - : commands - ? [commands] - : []; - if (commandList.length === 0) { - return null; - } - - return ( -
- Technical details -
- - {(command) => ( - - {command} - - )} - -
-
+ const execute = async (actionId: string) => { + const result = await ResourceActionsAPI.executeAction( + actionId, + 'Operator requested execution from the Patrol action review', ); + setLatestAudit(result.audit); + const verification = result.audit.verificationOutcome?.status; + if (result.state === 'completed' && verification === 'verified') { + notificationStore.success('Action completed and verified'); + } else if (result.state === 'completed') { + notificationStore.warning('Action completed, but verification was inconclusive'); + } else { + notificationStore.error(result.result?.errorMessage || 'Action failed'); + } }; - const renderRecoveryActions = (assistantLabel: string, onAssistantClick: (e: Event) => void) => ( -
- - - - - - -
- ); + const handleApproveAndRun = async (event: Event) => { + event.stopPropagation(); + const current = action(); + if (!current) return; + setBusyAction('approve'); + try { + const decision = await ResourceActionsAPI.decideAction( + current.action_id, + 'approved', + 'Approved from the Patrol action review', + ); + setLatestAudit(decision.audit); + await execute(current.action_id); + await refreshPatrol(); + } catch (error) { + notificationStore.error((error as Error).message || 'Failed to approve and run action'); + } finally { + setBusyAction(null); + } + }; + + const handleRun = async (event: Event) => { + event.stopPropagation(); + const current = action(); + if (!current) return; + setBusyAction('execute'); + try { + await execute(current.action_id); + await refreshPatrol(); + } catch (error) { + notificationStore.error((error as Error).message || 'Failed to run action'); + } finally { + setBusyAction(null); + } + }; + + const handleReject = async (event: Event) => { + event.stopPropagation(); + const current = action(); + if (!current) return; + setBusyAction('reject'); + try { + const decision = await ResourceActionsAPI.decideAction( + current.action_id, + 'rejected', + 'Rejected from the Patrol action review', + ); + setLatestAudit(decision.audit); + notificationStore.success('Action rejected'); + await refreshPatrol(); + } catch (error) { + notificationStore.error((error as Error).message || 'Failed to reject action'); + } finally { + setBusyAction(null); + } + }; + + const handleDiscuss = (event: Event) => { + event.stopPropagation(); + const current = action(); + const handoff = buildPatrolAssistantFindingHandoff({ + id: props.findingId, + title: props.findingTitle || 'Patrol finding', + subject: props.resourceName || 'affected resource', + description: + current?.plan.message || + investigation()?.summary || + 'Review the current Patrol finding and its governed action state.', + findingStatus: 'active', + investigationOutcome: props.investigationOutcome, + loopState: props.investigationOutcome || current?.state, + resourceId: props.resourceId || current?.resource_id, + resourceName: props.resourceName, + resourceType: props.resourceType, + pendingApproval: current + ? { + status: current.state, + targetName: props.resourceName || current.resource_id, + actionId: current.action_id, + actionApprovalPolicy: current.plan.approvalPolicy, + actionPlanExpiresAt: current.plan.expiresAt, + actionPlanMessage: current.plan.message, + actionPreflight: current.plan.preflight?.intendedChange, + actionDryRunSummary: current.plan.preflight?.dryRunSummary, + actionRequestedBy: 'pulse_patrol', + } + : null, + proposedFix: buildPatrolAssistantProposedFixBriefingInput( + current + ? { + description: current.plan.message || capabilityLabel(current.capability_name), + targetHost: props.resourceName || current.resource_id, + commandCount: 0, + destructive: false, + } + : null, + ), + }); + aiChatStore.open(handoff.context); + }; return ( -
- {/* Pending approval */} - - {(() => { - const approval = pendingApproval()!; - const approvalRisk = getApprovalRiskPresentation(approval.riskLevel); - return ( - <> -
- - - - Fix Available - - {approvalRisk.label} risk - -
-
-
{approval.context}
- -
Target: {approval.targetName}
-
- {renderTechnicalCommandDetails(approval.command)} -
-
- - - - - - - -
- - ); - })()} -
- - {/* Expired approval - show re-approve */} - - {(() => { - const fix = investigation()!.proposed_fix!; - const fixRisk = getApprovalRiskPresentation(fix.risk_level); - return ( - <> -
- - - - Fix Pending Approval - - {fixRisk.label} risk - - - approval expired - -
-
-
{fix.description}
- -
Target: {fix.target_host}
-
- {renderTechnicalCommandDetails(fix.commands)} -
- {renderRecoveryActions('Fix with Assistant', (e) => - handleFixWithAssistant(null, fix, e), - )} - - ); - })()} -
- - {/* Queued approval with missing detail payload - keep recovery path visible */} - - <> -
- - - - Fix Pending Approval - - details unavailable - +
+ + + Loading governed action…
-
-
- Patrol queued a fix for this finding, but the original approval details are no - longer available. + } + > + +
+
+ Action details unavailable +
+
+ This investigation predates the typed action lifecycle or its action record is + no longer available. It cannot be approved or executed from legacy fix data. +
+
+
-
- Regenerate the approval to continue, or rerun the investigation to let Patrol - rebuild the remediation plan. -
-
- {renderRecoveryActions('Discuss with Assistant', handleDiscussQueuedFix)} - - - - {/* Execution result */} - - - - - {/* Executed (from backend state, no local result) */} - -
- - - - - {props.investigationOutcome === 'fix_verified' - ? 'Fix verified — issue resolved' - : props.investigationOutcome === 'fix_verification_unknown' - ? 'Fix executed — verification inconclusive' - : 'Fix executed successfully'} - -
- - {(fix) => ( -
-
{fix().description}
- -
Target: {fix().target_host}
-
- -
{fix().rationale}
-
- {renderTechnicalCommandDetails(fix().commands)} -
- )} -
-
+ {(currentAction) => { + const presentation = createMemo(() => statePresentation(currentAction().state)); + const preflight = createMemo(() => currentAction().plan.preflight); + return ( +
+
+ + {capabilityLabel(currentAction().capability_name)} + + + {presentation().label} + + + + Rollback available + + +
- {/* Rejected by operator */} - -
- - Fix rejected before execution -
- - {(fix) => ( -
-
{fix().description}
- -
Target: {fix().target_host}
-
-
- )} -
- {renderRecoveryActions('Discuss with Assistant', handleDiscussQueuedFix)} -
+
+
+ {currentAction().plan.message || 'Patrol proposed a governed action.'} +
+
+ Target: {props.resourceName || currentAction().resource_id} +
+ +
Change: {preflight()!.intendedChange}
+
+ +
Dry run: {preflight()!.dryRunSummary}
+
+
- {/* Failed (from backend state, no local result) */} - -
- - - - - {props.investigationOutcome === 'fix_verification_failed' - ? 'Fix executed but issue persists' - : 'Fix execution failed'} - -
- - {(fix) => ( -
-
{fix().description}
- -
Target: {fix().target_host}
-
- -
{fix().rationale}
-
- {renderTechnicalCommandDetails(fix().commands)} -
- )} + 0}> +
+ + Safety and verification + +
    + {(check) =>
  • {check}
  • }
    + {(step) =>
  • {step}
  • }
    +
+
+
+ + +
+ {verificationStatus() === 'verified' + ? 'Outcome verified' + : 'Execution finished; verification was not conclusive'} +
+
+ +
+ {latestAudit()?.result?.errorMessage || + 'The action failed before verification.'} +
+
+ +
+ + + + + + + + +
+
+ ); + }}
diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 628fd2f4e..d7c848828 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -1,420 +1,237 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ApprovalExecutionResult, ApprovalRequest, InvestigationSession } from '@/api/ai'; +import type { InvestigationSession } from '@/api/ai'; +import type { ActionAuditRecord, PatrolActionReference } from '@/types/actionAudit'; import approvalSectionSource from '../ApprovalSection.tsx?raw'; import ApprovalSection from '../ApprovalSection'; -const state = vi.hoisted(() => ({ - pendingApprovals: [] as ApprovalRequest[], - hasAutoFix: false, - entitlements: { - subscription_state: 'expired', - trial_eligible: false, - } as { subscription_state: string; trial_eligible?: boolean } | null, -})); - -const getInvestigationMock = vi.hoisted(() => vi.fn<() => Promise>()); -const reapproveInvestigationFixMock = vi.hoisted(() => - vi.fn<() => Promise<{ approval_id: string; message: string }>>(), -); -const approveInvestigationFixMock = vi.hoisted(() => vi.fn()); -const denyInvestigationFixMock = vi.hoisted(() => vi.fn()); +const state = vi.hoisted(() => ({ hasAutoFix: true })); +const getInvestigationMock = vi.hoisted(() => vi.fn()); +const decideActionMock = vi.hoisted(() => vi.fn()); +const executeActionMock = vi.hoisted(() => vi.fn()); +const loadFindingsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const notificationSuccessMock = vi.hoisted(() => vi.fn()); +const notificationWarningMock = vi.hoisted(() => vi.fn()); const notificationErrorMock = vi.hoisted(() => vi.fn()); const openMock = vi.hoisted(() => vi.fn()); -vi.mock('@/api/ai', () => ({ - AIAPI: { - getInvestigation: getInvestigationMock, - reapproveInvestigationFix: reapproveInvestigationFixMock, +vi.mock('@/api/ai', () => ({ AIAPI: { getInvestigation: getInvestigationMock } })); +vi.mock('@/api/resourceActions', () => ({ + ResourceActionsAPI: { + decideAction: (...args: unknown[]) => decideActionMock(...args), + executeAction: (...args: unknown[]) => executeActionMock(...args), }, })); - vi.mock('@/stores/aiIntelligence', () => ({ - aiIntelligenceStore: { - get pendingApprovals() { - return state.pendingApprovals; - }, - get patrolPendingApprovals() { - return state.pendingApprovals; - }, - approveInvestigationFix: (...args: unknown[]) => approveInvestigationFixMock(...args), - denyInvestigationFix: (...args: unknown[]) => denyInvestigationFixMock(...args), - }, + aiIntelligenceStore: { loadFindings: (...args: unknown[]) => loadFindingsMock(...args) }, })); - vi.mock('@/stores/notifications', () => ({ notificationStore: { success: (...args: unknown[]) => notificationSuccessMock(...args), + warning: (...args: unknown[]) => notificationWarningMock(...args), error: (...args: unknown[]) => notificationErrorMock(...args), }, })); - vi.mock('@/stores/aiChat', () => ({ - aiChatStore: { - open: (...args: unknown[]) => openMock(...args), - }, + aiChatStore: { open: (...args: unknown[]) => openMock(...args) }, })); - vi.mock('@/stores/license', () => ({ hasFeature: (feature: string) => feature === 'ai_autofix' && state.hasAutoFix, })); -vi.mock('@/stores/licenseCommercial', () => ({ - canStartCommercialTrial: () => false, -})); +function actionReference(stateValue: PatrolActionReference['state']): PatrolActionReference { + return { + action_id: 'act-1', + proposal_id: 'proposal-1', + resource_id: 'vm:42', + capability_name: 'restart', + state: stateValue, + plan: { + actionId: 'act-1', + requestId: 'proposal-1', + allowed: true, + requiresApproval: stateValue === 'pending_approval', + approvalPolicy: stateValue === 'pending_approval' ? 'admin' : 'none', + rollbackAvailable: true, + message: 'Restart the unhealthy workload', + preflight: { + target: 'vm:42', + currentState: 'degraded', + intendedChange: 'Restart the workload', + dryRunAvailable: true, + dryRunSummary: 'Connectivity and dependency checks passed', + safetyChecks: ['Agent is connected'], + verificationSteps: ['Confirm the workload is healthy'], + }, + }, + }; +} -vi.mock('../RemediationStatus', () => ({ - RemediationStatus: (props: { result: ApprovalExecutionResult }) => ( -
{props.result.message}
- ), -})); +function investigation(action?: PatrolActionReference): InvestigationSession { + return { + id: 'investigation-1', + finding_id: 'finding-1', + session_id: 'session-1', + status: 'completed', + started_at: '2026-07-10T10:00:00Z', + turn_count: 2, + outcome: 'fix_queued', + action, + }; +} -describe('ApprovalSection', () => { +function audit( + stateValue: ActionAuditRecord['state'], + verification = 'unknown', +): ActionAuditRecord { + const action = actionReference(stateValue); + return { + id: action.action_id, + createdAt: '2026-07-10T10:00:00Z', + updatedAt: '2026-07-10T10:01:00Z', + state: stateValue, + request: { + requestId: 'proposal-1', + resourceId: action.resource_id, + capabilityName: action.capability_name, + reason: 'Recover the unhealthy workload', + requestedBy: 'pulse_patrol', + }, + plan: action.plan, + origin: { + surface: 'patrol', + findingId: 'finding-1', + investigationId: 'investigation-1', + proposalId: 'proposal-1', + }, + verificationOutcome: { status: verification }, + }; +} + +describe('ApprovalSection typed action lifecycle', () => { beforeEach(() => { - state.pendingApprovals = []; - state.hasAutoFix = false; - state.entitlements = { - subscription_state: 'expired', - trial_eligible: false, - }; - + state.hasAutoFix = true; getInvestigationMock.mockReset(); - reapproveInvestigationFixMock.mockReset(); - approveInvestigationFixMock.mockReset(); - denyInvestigationFixMock.mockReset(); + decideActionMock.mockReset(); + executeActionMock.mockReset(); + loadFindingsMock.mockClear(); notificationSuccessMock.mockReset(); + notificationWarningMock.mockReset(); notificationErrorMock.mockReset(); openMock.mockReset(); }); - it('keeps approval action loading indicators on the shared LoadingSpinner primitive', () => { - expect(approvalSectionSource).toContain('LoadingSpinner'); + afterEach(() => cleanup()); + + it('contains no live dependency on retired command-fix approval routes or raw commands', () => { + expect(approvalSectionSource).toContain('@/api/resourceActions'); + expect(approvalSectionSource).not.toContain('reapproveInvestigationFix'); + expect(approvalSectionSource).not.toContain('approveInvestigationFix'); + expect(approvalSectionSource).not.toContain('denyInvestigationFix'); + expect(approvalSectionSource).not.toContain('approval.command'); + expect(approvalSectionSource).not.toContain('proposed_fix.commands'); }); - it('keeps approval action controls on the shared Button primitive', () => { - expect(approvalSectionSource).toContain('@/components/shared/Button'); - expect(approvalSectionSource).toContain(' { + getInvestigationMock.mockResolvedValue(investigation(actionReference('pending_approval'))); + decideActionMock.mockResolvedValue({ + actionId: 'act-1', + state: 'approved', + approval: { + actor: 'operator', + method: 'api', + timestamp: '2026-07-10T10:01:00Z', + outcome: 'approved', + }, + audit: audit('approved'), + }); + executeActionMock.mockResolvedValue({ + actionId: 'act-1', + state: 'completed', + audit: audit('completed', 'verified'), + result: { success: true }, + }); + + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: /approve and run/i })); + + await waitFor(() => { + expect(decideActionMock).toHaveBeenCalledWith( + 'act-1', + 'approved', + 'Approved from the Patrol action review', + ); + expect(executeActionMock).toHaveBeenCalledWith( + 'act-1', + 'Operator requested execution from the Patrol action review', + ); + }); + expect(notificationSuccessMock).toHaveBeenCalledWith('Action completed and verified'); + }); + + it('rejects without executing', async () => { + getInvestigationMock.mockResolvedValue(investigation(actionReference('pending_approval'))); + decideActionMock.mockResolvedValue({ + actionId: 'act-1', + state: 'rejected', + approval: { + actor: 'operator', + method: 'api', + timestamp: '2026-07-10T10:01:00Z', + outcome: 'rejected', + }, + audit: audit('rejected'), + }); + + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: /^reject$/i })); + + await waitFor(() => + expect(decideActionMock).toHaveBeenCalledWith( + 'act-1', + 'rejected', + 'Rejected from the Patrol action review', + ), + ); + expect(executeActionMock).not.toHaveBeenCalled(); + }); + + it('runs a no-approval plan without fabricating a decision', async () => { + getInvestigationMock.mockResolvedValue(investigation(actionReference('planned'))); + executeActionMock.mockResolvedValue({ + actionId: 'act-1', + state: 'completed', + audit: audit('completed', 'unverified'), + result: { success: true }, + }); + + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: /run action/i })); + + await waitFor(() => expect(executeActionMock).toHaveBeenCalledTimes(1)); + expect(decideActionMock).not.toHaveBeenCalled(); + expect(notificationWarningMock).toHaveBeenCalledWith( + 'Action completed, but verification was inconclusive', ); }); - it('demotes raw commands behind technical details instead of making them the approval body', () => { - expect(approvalSectionSource).toContain('renderTechnicalCommandDetails'); - expect(approvalSectionSource).toContain('Technical details'); - expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(approval.command)}'); - expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(fix.commands)}'); - expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(fix().commands)}'); - expect(approvalSectionSource).not.toContain('{approval.command}
'); - expect(approvalSectionSource).not.toContain('{fix.commands![0]}'); - expect(approvalSectionSource).not.toContain('{fix().commands![0]}'); - }); - - afterEach(() => { - cleanup(); - }); - - it('uses governed decision wording for live approvals', async () => { - state.hasAutoFix = true; - state.pendingApprovals = [ - { - id: 'approval-1', - toolId: 'investigation_fix', - command: 'systemctl restart nginx', - targetType: 'investigation', - targetId: 'finding-1', - targetName: 'node-1', - context: 'Restart the workload service', - riskLevel: 'high', - status: 'pending', - requestedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + it('fails closed when only a legacy investigation artifact remains', async () => { + getInvestigationMock.mockResolvedValue({ + ...investigation(), + proposed_fix: { + id: 'legacy-fix', + description: 'Legacy command-shaped history', + commands: ['rm -rf /should-never-render'], + destructive: true, }, - ]; + approval_id: 'legacy-approval', + }); render(() => ); - expect(await screen.findByRole('button', { name: /approve fix/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument(); - expect(screen.queryByText(/approve & execute/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/^deny$/i)).not.toBeInTheDocument(); - }); - - it('keeps fix approvals out of commercial trial prompts', () => { - expect(approvalSectionSource).not.toContain('canStartCommercialTrial'); - expect(approvalSectionSource).not.toContain('runStartProTrialAction'); - expect(approvalSectionSource).not.toContain('start a free 14-day trial'); - expect(approvalSectionSource).not.toContain('commercialPosture'); - }); - - it('keeps approval metadata chips on the shared MetadataBadge primitive', () => { - expect(approvalSectionSource).toContain('MetadataBadge'); - expect(approvalSectionSource).toContain('APPROVAL_SECTION_BADGE_PROPS'); - expect(approvalSectionSource).toContain('approvalRisk.badgeTone'); - expect(approvalSectionSource).toContain('fixRisk.badgeTone'); - expect(approvalSectionSource).not.toContain('approvalRisk.badgeClass'); - expect(approvalSectionSource).not.toContain('fixRisk.badgeClass'); - expect(approvalSectionSource).not.toMatch(/px-1\.5 py-0\.5 text-\[10px\] font-medium rounded/); - }); - - it('keeps fix_queued findings actionable when approval and investigation details are unavailable', async () => { - getInvestigationMock.mockResolvedValue(null); - - render(() => ( - - )); - - expect(await screen.findByText('Fix Pending Approval')).toBeInTheDocument(); - expect(screen.getAllByText('Fix Pending Approval')).toHaveLength(1); - expect(screen.getByText('details unavailable')).toBeInTheDocument(); - expect( - screen.getByText(/original approval details are no longer available/i), - ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i })); - - expect(openMock).toHaveBeenCalledTimes(1); - const [context] = openMock.mock.calls[0]; - expect(context).toEqual( - expect.objectContaining({ - targetType: 'host', - targetId: 'host-1', - findingId: 'finding-1', - briefing: expect.objectContaining({ - sourceLabel: 'Pulse Patrol', - title: 'Patrol finding attached', - subject: 'CPU saturation on node-1', - statusLabel: expect.stringContaining('Fix Queued'), - }), - autonomousMode: false, - handoffResources: [{ id: 'host-1', name: 'node-1', node: undefined, type: 'host' }], - handoffActions: undefined, - context: expect.objectContaining({ - source: 'pulse-patrol-finding', - findingId: 'finding-1', - resourceId: 'host-1', - resourceName: 'node-1', - resourceType: 'host', - actionReferenceCount: 0, - }), - }), - ); - expect(context.handoffContext).toContain('[Patrol Finding Context]'); - expect(context.handoffContext).toContain('Finding ID: finding-1'); - expect(context.handoffContext).toContain( - 'Recover or regenerate the governed approval before execution', - ); - expect(context.handoffContext).toContain('Command Boundary:'); - }); - - it('opens Assistant from a pending Patrol approval without carrying raw command text', async () => { - state.pendingApprovals = [ - { - id: 'approval-1', - toolId: 'investigation_fix', - command: 'systemctl restart nginx', - targetType: 'investigation', - targetId: 'finding-1', - targetName: 'node-1', - context: 'Restart the workload service', - riskLevel: 'high', - status: 'pending', - requestedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), - }, - ]; - - render(() => ( - - )); - - fireEvent.click(await screen.findByRole('button', { name: /fix with assistant/i })); - - expect(openMock).toHaveBeenCalledTimes(1); - const [context] = openMock.mock.calls[0]; - expect(JSON.stringify(context)).not.toContain('systemctl restart nginx'); - expect(JSON.stringify(context)).not.toContain('Please execute this fix'); - expect(context).toEqual( - expect.objectContaining({ - targetType: 'agent', - targetId: 'agent-1', - findingId: 'finding-1', - briefing: expect.objectContaining({ - sourceLabel: 'Pulse Patrol', - title: 'Patrol finding attached', - subject: 'CPU saturation on node-1', - statusLabel: expect.stringContaining('Pending'), - commandSummary: '1 command recorded for approval context', - safetyNote: expect.stringContaining('Command details stay in approval context'), - }), - autonomousMode: false, - handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }], - handoffActions: [ - expect.objectContaining({ - findingId: 'finding-1', - approvalId: 'approval-1', - approvalStatus: 'pending', - actionRequiresApproval: true, - description: 'Restart the workload service', - riskLevel: 'high', - destructive: false, - targetHost: 'node-1', - targetResourceId: 'agent-1', - targetResourceName: 'node-1', - targetResourceType: 'agent', - }), - ], - context: expect.objectContaining({ - source: 'pulse-patrol-finding', - findingId: 'finding-1', - resourceId: 'agent-1', - resourceName: 'node-1', - resourceType: 'agent', - pendingApprovalId: 'approval-1', - actionReferenceCount: 1, - }), - }), - ); - expect(context.handoffContext).toContain('[Patrol Finding Context]'); - expect(context.handoffContext).toContain('Approval: approval-1'); - expect(context.handoffContext).toContain('Approval Status: pending'); - expect(context.handoffContext).toContain('Command Boundary:'); - expect(JSON.stringify(context.handoffActions)).not.toContain('systemctl restart nginx'); - expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx'); - }); - - it('opens Assistant from an expired approval with safe proposed-fix briefing metadata', async () => { - getInvestigationMock.mockResolvedValue({ - id: 'session-1', - finding_id: 'finding-1', - session_id: 'session-1', - status: 'completed', - started_at: '2026-05-06T12:00:00Z', - turn_count: 1, - outcome: 'fix_queued', - proposed_fix: { - id: 'fix-1', - description: 'Restart the workload service', - commands: ['systemctl restart nginx'], - risk_level: 'high', - destructive: true, - target_host: 'node-1', - rationale: 'Service is wedged after IO pressure.', - }, - }); - - render(() => ( - - )); - - expect(await screen.findByText('approval expired')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /fix with assistant/i })); - - expect(openMock).toHaveBeenCalledTimes(1); - const [context] = openMock.mock.calls[0]; - expect(JSON.stringify(context)).not.toContain('systemctl restart nginx'); - expect(context).toEqual( - expect.objectContaining({ - targetType: 'agent', - targetId: 'agent-1', - findingId: 'finding-1', - briefing: expect.objectContaining({ - sourceLabel: 'Pulse Patrol', - title: 'Patrol finding attached', - subject: 'CPU saturation on node-1', - statusLabel: expect.stringContaining('Fix Queued'), - commandSummary: '1 command recorded for approval context', - safetyNote: expect.stringContaining('destructive actions require governed approval'), - }), - autonomousMode: false, - handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }], - handoffActions: [ - expect.objectContaining({ - findingId: 'finding-1', - approvalId: undefined, - actionRequiresApproval: false, - description: 'Restart the workload service', - riskLevel: 'high', - destructive: true, - targetHost: 'node-1', - targetResourceId: 'agent-1', - targetResourceName: 'node-1', - targetResourceType: 'agent', - }), - ], - context: expect.objectContaining({ - source: 'pulse-patrol-finding', - findingId: 'finding-1', - resourceId: 'agent-1', - resourceName: 'node-1', - resourceType: 'agent', - pendingApprovalId: undefined, - actionReferenceCount: 1, - }), - }), - ); - expect(context.handoffContext).toContain('[Patrol Finding Context]'); - expect(context.handoffContext).toContain('Existing Action Artifact:'); - expect(context.handoffContext).toContain('Command Boundary:'); - expect(JSON.stringify(context.handoffActions)).not.toContain('systemctl restart nginx'); - expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx'); - }); - - it('recreates and executes a queued fix when autofix is available', async () => { - state.hasAutoFix = true; - getInvestigationMock.mockResolvedValue(null); - reapproveInvestigationFixMock.mockResolvedValue({ - approval_id: 'approval-2', - message: 'Approval recreated', - }); - approveInvestigationFixMock.mockResolvedValue({ - approved: true, - executed: true, - success: true, - output: 'ok', - exit_code: 0, - finding_id: 'finding-2', - message: 'Fix executed successfully', - } satisfies ApprovalExecutionResult); - - render(() => ); - - expect(await screen.findAllByRole('button', { name: /re-approve fix/i })).toHaveLength(1); - fireEvent.click(screen.getByRole('button', { name: /re-approve fix/i })); - - await waitFor(() => { - expect(reapproveInvestigationFixMock).toHaveBeenCalledWith('finding-2'); - expect(approveInvestigationFixMock).toHaveBeenCalledWith('approval-2'); - }); - expect(notificationSuccessMock).toHaveBeenCalledWith('Fix executed successfully'); + expect(await screen.findByText('Action details unavailable')).toBeInTheDocument(); + expect(screen.queryByText('rm -rf /should-never-render')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /approve/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /run action/i })).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index fff7bfc58..4529568a8 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -626,6 +626,38 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(handoff)).not.toContain('systemctl restart workload.service'); }); + it('keeps a typed pending action approval-bound without a legacy approval id', () => { + const handoff = buildPatrolAssessmentAssistantHandoff({ + assessment: { title: 'Issues detected' }, + activeFindings: [ + { + id: 'finding-typed-action', + title: 'Unhealthy workload', + severity: 'warning', + status: 'active', + resourceId: 'docker:container:web', + pendingApproval: { + id: '', + status: 'pending_approval', + riskLevel: 'governed', + requestedAt: '2026-07-10T18:00:00Z', + actionId: 'action-typed-1', + actionApprovalPolicy: 'admin', + actionRequestedBy: 'pulse_patrol', + }, + }, + ], + }); + + expect(handoff.context.handoffActions?.[0]).toMatchObject({ + findingId: 'finding-typed-action', + actionId: 'action-typed-1', + actionApprovalPolicy: 'admin', + actionRequiresApproval: true, + }); + expect(handoff.context.handoffActions?.[0].approvalId).toBeUndefined(); + }); + it('builds a model-only Assistant handoff for a Patrol run runtime failure', () => { const run: PatrolRunRecord = { id: 'run-runtime-error', diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 6354cd6ab..c5d212b68 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -1265,7 +1265,11 @@ function buildPatrolFindingHandoffAction( actionId: pendingApproval.actionId || undefined, actionRequestedBy: pendingApproval.actionRequestedBy || undefined, actionApprovalPolicy: pendingApproval.actionApprovalPolicy || undefined, - actionRequiresApproval: Boolean(approvalId), + actionRequiresApproval: Boolean( + approvalId || + pendingApproval.status === 'pending_approval' || + (pendingApproval.actionApprovalPolicy && pendingApproval.actionApprovalPolicy !== 'none'), + ), actionPlanExpiresAt: pendingApproval.actionPlanExpiresAt || undefined, actionPlanMessage: pendingApproval.actionPlanMessage || undefined, actionPreflight: pendingApproval.actionPreflight || undefined, diff --git a/frontend-modern/src/types/actionAudit.ts b/frontend-modern/src/types/actionAudit.ts index e26dde82f..9dd4b2e48 100644 --- a/frontend-modern/src/types/actionAudit.ts +++ b/frontend-modern/src/types/actionAudit.ts @@ -1,11 +1,5 @@ export type ActionAuditState = - | 'planned' - | 'pending_approval' - | 'approved' - | 'rejected' - | 'executing' - | 'completed' - | 'failed'; + 'planned' | 'pending_approval' | 'approved' | 'rejected' | 'executing' | 'completed' | 'failed'; export type ActionAuditApprovalPolicy = 'none' | 'dry_run_only' | 'admin' | 'mfa' | string; @@ -79,10 +73,7 @@ export interface ActionAuditExecutionResult { export type ActionVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed' | string; export type ActionAuditRefusalPrefix = - | 'plan_drift:' - | 'action_plan_expired:' - | 'action_dry_run_only:' - | 'resource_remediation_locked:'; + 'plan_drift:' | 'action_plan_expired:' | 'action_dry_run_only:' | 'resource_remediation_locked:'; export interface ActionVerificationOutcome { status: ActionVerificationStatus; @@ -96,12 +87,37 @@ export interface ActionAuditRecord { state: ActionAuditState; request: ActionAuditRequest; plan: ActionAuditPlan; + origin?: ActionAuditOrigin; approvals?: ActionAuditApprovalRecord[]; result?: ActionAuditExecutionResult; verification?: ActionVerificationResult; verificationOutcome?: ActionVerificationOutcome; } +export interface ActionAuditOrigin { + surface: string; + findingId?: string; + investigationId?: string; + proposalId?: string; +} + +// PatrolActionReference is the compact investigation projection of the +// canonical action audit. Lifecycle state and proposal parameters remain +// authoritative in the action API; Patrol never reconstructs command fixes. +export interface PatrolActionReference { + action_id: string; + proposal_id?: string; + resource_id: string; + capability_name: string; + state: ActionAuditState; + plan: ActionAuditPlan; +} + +export interface PendingActionsResponse { + actions: ActionAuditRecord[]; + count: number; +} + export interface ActionAuditListResponse { audits: ActionAuditRecord[]; count: number; diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index e7a1b0cef..7d3c439a5 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -325,12 +325,7 @@ export interface FindingCompactBadgePresentation { } export type FindingPatrolWorkflowStage = - | 'investigating' - | 'approval' - | 'verification' - | 'attention' - | 'recorded' - | 'paused'; + 'investigating' | 'approval' | 'verification' | 'attention' | 'recorded' | 'paused'; export interface FindingPatrolWorkflowPresentation { stage: FindingPatrolWorkflowStage; @@ -678,13 +673,7 @@ export interface PatrolActionableStatePresentation { } export type PatrolFindingRowScaffoldItemId = - | 'affected' - | 'checked' - | 'next-step' - | 'problem' - | 'verification' - | 'workflow' - | 'why'; + 'affected' | 'checked' | 'next-step' | 'problem' | 'verification' | 'workflow' | 'why'; export interface PatrolFindingRowScaffoldItem { id: PatrolFindingRowScaffoldItemId; @@ -721,11 +710,22 @@ export function getPatrolFindingActionableState( } function getPatrolFindingVerificationSummary( - finding: Pick, + finding: Pick< + UnifiedFinding, + 'investigationStatus' | 'investigationOutcome' | 'investigationRecord' + >, ): string { + const actionState = finding.investigationRecord?.action?.state; + if (finding.investigationOutcome === 'fix_queued') { + if (actionState === 'planned') return 'The governed action is ready to run.'; + if (actionState === 'approved') return 'The governed action is approved and ready to run.'; + if (actionState === 'executing') return 'The governed action is running; verification follows.'; + if (actionState === 'pending_approval') + return 'Waiting for an approval decision before the action runs.'; + } switch (finding.investigationOutcome) { case 'fix_queued': - return 'Waiting for approval before any fix runs.'; + return 'Waiting for the governed action record before any change runs.'; case 'fix_executed': return 'Fix ran; verification is in progress.'; case 'fix_verified': @@ -762,9 +762,13 @@ function getPatrolFindingWorkflowSummary( switch (workflow.stage) { case 'approval': - return workflow.label === 'Approve or reject' - ? 'Review evidence first; no change runs until the proposed fix is approved, then Patrol verifies the outcome.' - : 'Recover the queued fix before any action can run, then verify the outcome after a decision.'; + if (workflow.label === 'Approve or reject') { + return 'Review evidence first; no change runs until the typed action is approved, then Patrol verifies the outcome.'; + } + if (workflow.label === 'Run action' || workflow.label === 'Run approved action') { + return 'Review the typed plan, run it through the governed action lifecycle, then verify the outcome.'; + } + return 'Recover the queued action before any change can run, then verify the outcome after a decision.'; case 'verification': return 'The governed action ran; review follow-up evidence before closing the issue.'; case 'attention': @@ -778,10 +782,7 @@ function getPatrolFindingWorkflowSummary( } } -const getNonEmptyPresentationText = ( - value: string | undefined, - fallback: string, -): string => { +const getNonEmptyPresentationText = (value: string | undefined, fallback: string): string => { const normalized = String(value || '').trim(); return normalized || fallback; }; @@ -1034,9 +1035,9 @@ export const hasFindingInvestigationDetails = ( ): boolean => Boolean( finding.investigationSessionId?.trim() || - finding.investigationStatus || - finding.investigationOutcome || - (finding.investigationAttempts ?? 0) > 0, + finding.investigationStatus || + finding.investigationOutcome || + (finding.investigationAttempts ?? 0) > 0, ); // hasFindingInvestigationHandoffPointer is the narrower check used by the @@ -1081,7 +1082,7 @@ export const isPatrolInvestigationFixApproval = ( ): boolean => approval.toolId === 'investigation_fix'; export const doesFindingNeedAttention = ( - finding: Pick, + finding: Pick, approvals: Pick[] = [], ): boolean => { if (finding.status !== 'active' || !finding.investigationOutcome) { @@ -1094,7 +1095,8 @@ export const doesFindingNeedAttention = ( return ( finding.investigationOutcome === 'fix_queued' && - !hasPendingInvestigationFixApproval(finding.id, approvals) + !hasPendingInvestigationFixApproval(finding.id, approvals) && + !finding.investigationRecord?.action ); }; @@ -1109,6 +1111,7 @@ export const getFindingPatrolWorkflowPresentation = ( | 'title' | 'investigationStatus' | 'investigationOutcome' + | 'investigationRecord' | 'loopState' >, approvals: Pick[] = [], @@ -1149,6 +1152,40 @@ export const getFindingPatrolWorkflowPresentation = ( }; } + const typedActionState = finding.investigationRecord?.action?.state; + if (typedActionState === 'pending_approval') { + return { + stage: 'approval', + label: 'Approve or reject', + detail: 'A typed governed action is waiting for an operator decision.', + tone: 'warning', + }; + } + if (typedActionState === 'planned') { + return { + stage: 'approval', + label: 'Run action', + detail: 'The typed action plan is ready to run under its declared policy.', + tone: 'info', + }; + } + if (typedActionState === 'approved') { + return { + stage: 'approval', + label: 'Run approved action', + detail: 'The typed action is approved; execution remains a separate operator step.', + tone: 'success', + }; + } + if (typedActionState === 'executing') { + return { + stage: 'verification', + label: 'Action running', + detail: 'The governed action is running and will publish its verification result.', + tone: 'info', + }; + } + const hasLiveApproval = hasPendingInvestigationFixApproval(finding.id, approvals, now); if (hasLiveApproval) { return { @@ -1184,9 +1221,9 @@ export const getFindingPatrolWorkflowPresentation = ( case 'fix_queued': return { stage: 'approval', - label: 'Review fix', + label: 'Recover action', detail: - 'A fix was queued, but no live approval is available. Expand the finding to recover it.', + 'An older queued outcome has no canonical action reference. Expand the finding to review recovery options.', tone: 'warning', }; case 'fix_executed': diff --git a/internal/agentcapabilities/errors.go b/internal/agentcapabilities/errors.go index d22854d6b..dbdf7e6bd 100644 --- a/internal/agentcapabilities/errors.go +++ b/internal/agentcapabilities/errors.go @@ -29,6 +29,9 @@ const ( AgentErrCodeActionPlanDrift = "action_plan_drift" AgentErrCodeResourceRemediationLocked = "resource_remediation_locked" AgentErrCodeActionExecutorUnavailable = "action_executor_unavailable" + AgentErrCodeActionQueueUnavailable = "action_queue_unavailable" + AgentErrCodeActionQueueQueryFailed = "action_queue_query_failed" + AgentErrCodeActionQueueEncodeFailed = "action_queue_encode_failed" ) // ErrorEnvelope is the stable failure shape emitted by Pulse Intelligence diff --git a/internal/ai/chat/service_investigation.go b/internal/ai/chat/service_investigation.go index 8bcb9f2df..167095be6 100644 --- a/internal/ai/chat/service_investigation.go +++ b/internal/ai/chat/service_investigation.go @@ -48,6 +48,56 @@ type InvestigationRunResult struct { OutputTokens int } +// InvestigationRunError preserves the two independent failure channels from +// an investigation run. Proposal-only failures are valid completed runs that +// require operator attention; RunErr means the provider/runtime itself failed +// and must never be collapsed into that completed outcome. +type InvestigationRunError struct { + runErr error + proposalErr error +} + +// NewInvestigationRunError constructs a two-channel investigation failure. +// The package is internal; the exported constructor lets the API adapter's +// boundary tests exercise the same concrete error it receives at runtime. +func NewInvestigationRunError(runErr, proposalErr error) *InvestigationRunError { + if runErr == nil && proposalErr == nil { + return nil + } + return &InvestigationRunError{runErr: runErr, proposalErr: proposalErr} +} + +func (e *InvestigationRunError) Error() string { + if e == nil { + return "" + } + return errors.Join(e.runErr, e.proposalErr).Error() +} + +// Unwrap preserves errors.Is/errors.As behavior for both failure channels. +func (e *InvestigationRunError) Unwrap() []error { + if e == nil { + return nil + } + return []error{e.runErr, e.proposalErr} +} + +// RunFailure returns the provider/runtime failure, if any. +func (e *InvestigationRunError) RunFailure() error { + if e == nil { + return nil + } + return e.runErr +} + +// ProposalFailure returns the proposal-channel failure, if any. +func (e *InvestigationRunError) ProposalFailure() error { + if e == nil { + return nil + } + return e.proposalErr +} + // ExecuteInvestigationStream runs one Patrol investigation under the // investigation execution profile and returns the structured result. // Proposal-channel violations (ambiguity, integrity, failed-only @@ -176,7 +226,7 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat // run: any error nils it, and simultaneous run/proposal errors // are both preserved. result.Proposal = nil - return result, errors.Join(runErr, proposalErr) + return result, NewInvestigationRunError(runErr, proposalErr) } return result, nil } diff --git a/internal/ai/investigation_records.go b/internal/ai/investigation_records.go index 27e852b9c..f4ee5ab0a 100644 --- a/internal/ai/investigation_records.go +++ b/internal/ai/investigation_records.go @@ -63,6 +63,7 @@ func BuildFindingInvestigationRecord(f *Finding, session *InvestigationSession) record.CompletedAt = normalized.CompletedAt record.ToolsUsed = uniqueNonEmptyStrings(normalized.ToolsUsed) record.ApprovalID = strings.TrimSpace(normalized.ApprovalID) + record.Action = aicontracts.CloneActionReference(normalized.Action) record.Error = strings.TrimSpace(normalized.Error) if summary := strings.TrimSpace(normalized.Summary); summary != "" { record.Conclusion = summary diff --git a/internal/ai/investigation_records_test.go b/internal/ai/investigation_records_test.go index d910da9c4..d75d4fff5 100644 --- a/internal/ai/investigation_records_test.go +++ b/internal/ai/investigation_records_test.go @@ -99,6 +99,26 @@ func TestBuildFindingInvestigationRecord_FromSession(t *testing.T) { } } +func TestBuildFindingInvestigationRecordCarriesTypedActionReference(t *testing.T) { + now := time.Now().UTC() + finding := &Finding{ID: "finding-1", ResourceID: "vm:42", DetectedAt: now, InvestigationOutcome: string(InvestigationOutcomeFixQueued)} + session := &InvestigationSession{ + ID: "investigation-1", FindingID: finding.ID, StartedAt: now, Outcome: aicontracts.OutcomeFixQueued, + Action: &aicontracts.ActionReference{ + ActionID: "act-1", ProposalID: "proposal-1", ResourceID: "vm:42", CapabilityName: "restart", State: "pending_approval", + Plan: aicontracts.ActionPlanInfo{ActionID: "act-1", PredictedBlastRadius: []string{"vm:42"}}, + }, + } + record := BuildFindingInvestigationRecord(finding, session) + if record == nil || record.Action == nil || record.Action.ActionID != "act-1" { + t.Fatalf("record action = %#v", record) + } + session.Action.Plan.PredictedBlastRadius[0] = "mutated" + if record.Action.Plan.PredictedBlastRadius[0] != "vm:42" { + t.Fatal("record action reference must be an immutable copy") + } +} + func TestEmptyInvestigationRecord_NormalizesRollback(t *testing.T) { record := aicontracts.EmptyInvestigationRecord() if record.Rollback == nil || len(record.Rollback) != 0 { diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index 270f24b23..e78a0e308 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -1122,25 +1122,12 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) { if pushCb != nil { switch latest.InvestigationOutcome { case string(InvestigationOutcomeFixQueued): - approvalID := "" - riskLevel := "" - if latestInvestigation != nil { - approvalID = latestInvestigation.ApprovalID - if latestInvestigation.ProposedFix != nil { - riskLevel = latestInvestigation.ProposedFix.RiskLevel - } + if latestInvestigation != nil && latestInvestigation.Action != nil && latestInvestigation.Action.State == "pending_approval" { + pushCb(relay.NewActionDecisionNotification( + latestInvestigation.Action.ActionID, + latest.Title, + )) } - if approvalID == "" { - log.Warn(). - Str("finding_id", latest.ID). - Str("investigation_session_id", latest.InvestigationSessionID). - Msg("Investigation queued for approval but approval ID missing") - } - pushCb(relay.NewApprovalRequestNotification( - approvalID, - latest.Title, - riskLevel, - )) case string(InvestigationOutcomeFixExecuted), string(InvestigationOutcomeFixVerified): pushCb(relay.NewFixCompletedNotification(latest.ID, latest.Title, true)) case string(InvestigationOutcomeFixFailed), string(InvestigationOutcomeFixVerificationFailed): @@ -1163,6 +1150,44 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) { Msg("Triggered autonomous investigation for finding") } +// 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) { + if p == nil || p.findings == nil { + return + } + finding := p.findings.Get(findingID) + if finding == nil { + return + } + p.mu.RLock() + pushUnified := p.unifiedFindingCallback + resolveUnified := p.unifiedFindingResolver + pushNotify := p.pushNotifyCallback + p.mu.RUnlock() + if pushUnified != nil { + pushUnified(finding) + } + if finding.ResolvedAt != nil && resolveUnified != nil { + resolveUnified(finding.ID) + } + if pushNotify == nil { + return + } + switch InvestigationOutcome(finding.InvestigationOutcome) { + case InvestigationOutcomeFixVerified: + pushNotify(relay.NewActionOutcomeNotification(finding.ID, finding.Title, "verified")) + case InvestigationOutcomeFixVerificationFailed: + pushNotify(relay.NewActionOutcomeNotification(finding.ID, finding.Title, "failed")) + case InvestigationOutcomeFixVerificationUnknown: + pushNotify(relay.NewActionOutcomeNotification(finding.ID, finding.Title, "unverified")) + case InvestigationOutcomeFixFailed: + pushNotify(relay.NewActionOutcomeNotification(finding.ID, finding.Title, "execution_failed")) + } +} + // VerifyFixResolved runs a lightweight scoped patrol to check if the issue // identified by the given finding has been resolved after a fix was executed. // It bypasses tryStartRun (the patrol mutex) because verification runs inline diff --git a/internal/api/actions.go b/internal/api/actions.go index 49784fe38..dc35b051e 100644 --- a/internal/api/actions.go +++ b/internal/api/actions.go @@ -16,6 +16,7 @@ import ( const maxActionPlanRequestBytes = 1 << 20 const maxActionDecisionRequestBytes = 64 << 10 const maxActionExecutionRequestBytes = 64 << 10 +const maxPendingActionAudits = 100 // ActionExecutor is the API-facing name for the canonical action lifecycle // execution contract. The interface is owned by internal/actionlifecycle; @@ -49,6 +50,11 @@ type actionExecutionResponse struct { Audit unified.ActionAuditRecord `json:"audit"` } +type pendingActionsResponse struct { + Actions []unified.ActionAuditRecord `json:"actions"` + Count int `json:"count"` +} + // ActionLifecycle returns the shared transport-independent action lifecycle // service bound to this handler set's registry, store, executor, and // completion publisher. The REST handlers below and any in-process broker @@ -100,6 +106,38 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque } } +// HandleListPendingActions returns the canonical decision queue. It is not an +// audit-log endpoint and has no enterprise audit-log entitlement dependency; +// authorization is the same ai:execute scope required to decide an action. +func (h *ResourceHandlers) HandleListPendingActions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + store, err := h.getStore(GetOrgID(r.Context())) + if err != nil || store == nil { + writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action decision queue is not available") + return + } + reader, ok := store.(unified.PendingActionAuditReader) + if !ok { + writeJSONError(w, http.StatusServiceUnavailable, agentcapabilities.AgentErrCodeActionQueueUnavailable, "Action decision queue is not available") + return + } + actions, err := reader.GetPendingActionAudits(maxPendingActionAudits) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueQueryFailed, "Failed to query pending actions") + return + } + if actions == nil { + actions = []unified.ActionAuditRecord{} + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: actions, Count: len(actions)}); err != nil { + writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions") + } +} + func writeActionPlanError(w http.ResponseWriter, err error) { var validationErr *actionplanner.ValidationError var notFound *actionlifecycle.ResourceNotFoundError diff --git a/internal/api/actions_test.go b/internal/api/actions_test.go index c42d0dda0..e1ff4ba7d 100644 --- a/internal/api/actions_test.go +++ b/internal/api/actions_test.go @@ -215,6 +215,49 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { } } +func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T) { + h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + store, err := h.getStore("default") + if err != nil { + t.Fatalf("get store: %v", err) + } + now := time.Now().UTC() + for _, record := range []unified.ActionAuditRecord{ + { + ID: "act-pending", CreatedAt: now, UpdatedAt: now, State: unified.ActionStatePending, + Request: unified.ActionRequest{RequestID: "proposal-1", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover workload", RequestedBy: "pulse_patrol"}, + Plan: unified.ActionPlan{ActionID: "act-pending", RequestID: "proposal-1", Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin}, + Origin: &unified.ActionOrigin{Surface: patrolActionOriginSurface, FindingID: "finding-1", InvestigationID: "investigation-1", ProposalID: "proposal-1"}, + }, + { + ID: "act-completed", CreatedAt: now, UpdatedAt: now, State: unified.ActionStateCompleted, + Request: unified.ActionRequest{RequestID: "proposal-2", ResourceID: "vm:43", CapabilityName: "restart", Reason: "Recovered", RequestedBy: "pulse_patrol"}, + Plan: unified.ActionPlan{ActionID: "act-completed", RequestID: "proposal-2", Allowed: true}, + }, + } { + if err := store.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit(%s): %v", record.ID, err) + } + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/actions/pending", nil) + h.HandleListPendingActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response pendingActionsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Count != 1 || len(response.Actions) != 1 || response.Actions[0].ID != "act-pending" { + t.Fatalf("pending response = %#v", response) + } + if response.Actions[0].Origin == nil || response.Actions[0].Origin.InvestigationID != "investigation-1" { + t.Fatalf("pending action lost Patrol origin: %#v", response.Actions[0].Origin) + } +} + func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index aba09113e..7fe945f14 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2160,6 +2160,17 @@ func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context // onto the public contract sentinels so enterprise outcome mapping can // key on errors.Is without importing internal packages. func mapInvestigationProposalError(err error) error { + var runErr *chat.InvestigationRunError + if errors.As(err, &runErr) { + return aicontracts.NewOrchestratorInvestigationError( + runErr.RunFailure(), + mapInvestigationProposalSentinel(runErr.ProposalFailure()), + ) + } + return mapInvestigationProposalSentinel(err) +} + +func mapInvestigationProposalSentinel(err error) error { switch { case err == nil: return nil @@ -7392,11 +7403,15 @@ 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 { + return + } if !findingsStore.UpdateInvestigationOutcome(findingID, outcome) { log.Warn().Str("findingID", findingID).Msg("Finding not found for outcome update") return } + patrol.PublishFindingLifecycleUpdate(findingID) log.Info().Str("findingID", findingID).Str("outcome", outcome).Msg("Updated finding investigation outcome") } @@ -7700,6 +7715,8 @@ func (h *AISettingsHandler) HandleGetInvestigation(w http.ResponseWriter, r *htt writeErrorResponse(w, http.StatusNotFound, "not_found", "No investigation found for this finding", nil) return } + orgID := GetOrgID(r.Context()) + investigation = h.hydratePatrolInvestigationAction(orgID, investigation) normalizedInvestigation := investigation.NormalizeCollections() w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/ai_handlers_investigation_additional_test.go b/internal/api/ai_handlers_investigation_additional_test.go index 88bbf28ab..1b22ccba5 100644 --- a/internal/api/ai_handlers_investigation_additional_test.go +++ b/internal/api/ai_handlers_investigation_additional_test.go @@ -18,6 +18,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" ) @@ -222,6 +223,86 @@ func (s *testInvestigationStore) CountFixed() int { return 0 } func (s *testInvestigationStore) Cleanup(_ time.Duration) int { return 0 } func (s *testInvestigationStore) EnforceSizeLimit(_ int) int { return 0 } +func TestReconcilePatrolActionTransitionUsesAuthoritativeAudit(t *testing.T) { + investigations := newTestInvestigationStore() + investigation := investigations.Create("finding-1", "session-1") + audits := unifiedresources.NewMemoryStore() + now := time.Now().UTC() + pending := unifiedresources.ActionAuditRecord{ + ID: "act-1", CreatedAt: now, UpdatedAt: now, State: unifiedresources.ActionStatePending, + 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, RequiresApproval: true}, + Origin: &unifiedresources.ActionOrigin{Surface: patrolActionOriginSurface, FindingID: "finding-1", InvestigationID: investigation.ID, ProposalID: "proposal-1"}, + } + if err := audits.RecordActionAudit(pending); err != nil { + t.Fatalf("RecordActionAudit(pending): %v", err) + } + completed := pending + completed.State = unifiedresources.ActionStateCompleted + completed.UpdatedAt = now.Add(time.Second) + completed.VerificationOutcome = unifiedresources.VerificationOutcome{Status: unifiedresources.VerificationVerified} + if err := audits.RecordActionAudit(completed); err != nil { + t.Fatalf("RecordActionAudit(completed): %v", err) + } + + handler := &AISettingsHandler{ + investigationStores: map[string]aicontracts.InvestigationStore{"default": investigations}, + resourceStoreProvider: func(string) (unifiedresources.ResourceStore, error) { return audits, nil }, + } + // A stale callback payload must not regress the already-completed audit. + handler.ReconcilePatrolActionTransition("default", pending) + got := investigations.Get(investigation.ID) + if got.Action == nil || got.Action.ActionID != "act-1" || got.Action.State != string(unifiedresources.ActionStateCompleted) { + t.Fatalf("reconciled action = %#v, want authoritative completed action", got.Action) + } + if got.Outcome != aicontracts.OutcomeFixVerified { + t.Fatalf("outcome = %q, want %q", got.Outcome, aicontracts.OutcomeFixVerified) + } +} + +func TestHydratePatrolInvestigationActionRepairsMissedCallbackByOrigin(t *testing.T) { + investigations := newTestInvestigationStore() + investigation := investigations.Create("finding-1", "session-1") + audits := unifiedresources.NewMemoryStore() + record := unifiedresources.ActionAuditRecord{ + ID: "act-missed", 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-missed", RequestID: "proposal-1", Allowed: true, RequiresApproval: true}, + Origin: &unifiedresources.ActionOrigin{Surface: patrolActionOriginSurface, FindingID: "finding-1", InvestigationID: investigation.ID, ProposalID: "proposal-1"}, + } + if err := audits.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit: %v", err) + } + handler := &AISettingsHandler{ + investigationStores: map[string]aicontracts.InvestigationStore{"default": investigations}, + resourceStoreProvider: func(string) (unifiedresources.ResourceStore, error) { return audits, nil }, + } + + hydrated := handler.hydratePatrolInvestigationAction("default", investigation) + if hydrated.Action == nil || hydrated.Action.ActionID != "act-missed" || hydrated.Action.State != string(unifiedresources.ActionStateRejected) { + t.Fatalf("hydrated action = %#v", hydrated.Action) + } + if hydrated.Outcome != aicontracts.OutcomeFixRejected { + t.Fatalf("hydrated outcome = %q, want rejected", hydrated.Outcome) + } + persisted := investigations.Get(investigation.ID) + if persisted.Action == nil || persisted.Action.ActionID != "act-missed" || persisted.Outcome != aicontracts.OutcomeFixRejected { + t.Fatalf("persisted repair = %#v", persisted) + } +} + +func TestPatrolOutcomeForActionAuditPreservesInconclusiveVerification(t *testing.T) { + for _, status := range []unifiedresources.VerificationStatus{unifiedresources.VerificationUnknown, unifiedresources.VerificationUnverified} { + audit := unifiedresources.ActionAuditRecord{ + State: unifiedresources.ActionStateCompleted, + VerificationOutcome: unifiedresources.VerificationOutcome{Status: status}, + } + if got := patrolOutcomeForActionAudit(audit); got != aicontracts.OutcomeFixVerificationUnknown { + t.Fatalf("status %q mapped to %q, want verification_unknown", status, got) + } + } +} + type stubInvestigationOrchestrator struct { session *ai.InvestigationSession reinvestigateCh chan reinvestigateCall diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index c2c2b8c5e..efe42f287 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "net/url" @@ -17,6 +18,8 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" @@ -3640,3 +3643,20 @@ func TestOrchestratorAndChatAdaptersMapTheSameMessageFields(t *testing.T) { t.Error("chatServiceAdapter.GetMessages must route through adaptChatMessage") } } + +func TestMapInvestigationProposalErrorPreservesSimultaneousRunFailure(t *testing.T) { + runFailure := errors.New("provider stream failed") + coreErr := chat.NewInvestigationRunError(runFailure, tools.ErrProposalAmbiguous) + + mapped := mapInvestigationProposalError(coreErr) + if !errors.Is(mapped, runFailure) { + t.Fatal("mapped error lost the provider/runtime failure") + } + if !errors.Is(mapped, aicontracts.ErrInvestigationProposalAmbiguous) { + t.Fatal("mapped error lost the public proposal sentinel") + } + var investigationErr *aicontracts.OrchestratorInvestigationError + if !errors.As(mapped, &investigationErr) || investigationErr.RunFailure() == nil { + t.Fatalf("mapped error = %#v, want a runtime-bearing investigation error", mapped) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0db3a69b0..cc457f0e6 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -19616,8 +19616,9 @@ func TestContract_OperatorStateWriteEmitsStableErrorTokens(t *testing.T) { // carry on failure"; this test enforces that claim by reading every // writeJSONError call from the two agent-surface handler files and // asserting each emitted code is either (a) declared by the matching -// capability, or (b) one of the three cross-cutting codes the auth -// middleware emits universally. Drift in either direction is a +// capability, (b) one of the cross-cutting codes the auth middleware +// emits universally, or (c) an explicitly enumerated internal-only +// failure code. Drift in either direction is a // contract regression — emitting an undeclared code silently breaks // agents that branch on the closed set; declaring a code the // handler never emits misleads agents into writing dead-code paths. @@ -19660,6 +19661,9 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T) "action_execution_persist_failed": true, "action_execution_encode_failed": true, "action_execution_failed": true, + "action_queue_unavailable": true, + "action_queue_query_failed": true, + "action_queue_encode_failed": true, "action_not_executing": true, "action_policy_validation_failed": true, "action_plan_validation_failed": true, @@ -19691,6 +19695,9 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T) "AgentErrCodeActionPlanDrift": agentcapabilities.AgentErrCodeActionPlanDrift, "AgentErrCodeResourceRemediationLocked": agentcapabilities.AgentErrCodeResourceRemediationLocked, "AgentErrCodeActionExecutorUnavailable": agentcapabilities.AgentErrCodeActionExecutorUnavailable, + "AgentErrCodeActionQueueUnavailable": agentcapabilities.AgentErrCodeActionQueueUnavailable, + "AgentErrCodeActionQueueQueryFailed": agentcapabilities.AgentErrCodeActionQueueQueryFailed, + "AgentErrCodeActionQueueEncodeFailed": agentcapabilities.AgentErrCodeActionQueueEncodeFailed, } // Extract every emitted shared code from writeJSONError / @@ -19736,13 +19743,15 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T) } } - // Every emitted code must be either declared somewhere in the - // manifest OR a cross-cutting code. + // Every emitted code must be declared somewhere in the manifest, + // cross-cutting, or explicitly internal-only. Internal-only codes are + // still shared constants so handlers and clients cannot invent local + // spellings, but they do not describe an agent capability branch. for code := range emitted { - if declared[code] || crossCutting[code] { + if declared[code] || crossCutting[code] || internalOnlyCodes[code] { continue } - t.Errorf("handler emits %q but no capability in the manifest declares it and it is not a documented cross-cutting code — drift here breaks agents that branch on the closed set", code) + t.Errorf("handler emits %q but it is neither manifest-declared, cross-cutting, nor explicitly internal-only — drift here breaks clients that branch on the closed set", code) } // Every manifest-declared code must have a matching emission diff --git a/internal/api/patrol_action_broker_test.go b/internal/api/patrol_action_broker_test.go index 0099d17cb..f77f4b8d7 100644 --- a/internal/api/patrol_action_broker_test.go +++ b/internal/api/patrol_action_broker_test.go @@ -1,15 +1,20 @@ package api import ( + "bytes" "context" "errors" + "net/http" + "net/http/httptest" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/relay" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func newPatrolBrokerTestHandlers(t *testing.T, minimumApproval unified.ActionApprovalLevel) (*ResourceHandlers, *stubActionExecutor) { @@ -240,3 +245,87 @@ func TestPatrolActionBrokerSubmitPublishesOrgScopedTransition(t *testing.T) { t.Fatalf("transition origin = %#v", gotOrigin) } } + +func TestPatrolTypedActionJourneyDetectPlanApproveExecuteVerifyAndReconcile(t *testing.T) { + resources, executor := newPatrolBrokerTestHandlers(t, unified.ApprovalAdmin) + executor.result = &unified.ExecutionResult{ + Success: true, + Output: "restart dispatched", + Verification: &unified.ActionVerificationResult{ + Ran: true, Success: true, RanAt: time.Now().UTC(), Note: "workload health confirmed", + }, + } + aiHandler, patrol, _, _ := setupAIHandlerWithPatrol(t) + pushes := make(chan relay.PushNotificationPayload, 1) + patrol.SetPushNotifyCallback(func(payload relay.PushNotificationPayload) { pushes <- payload }) + finding := addPatrolFindingForResource(t, patrol, "finding-1", time.Now().UTC(), "vm:42", "Unhealthy workload") + investigations := newTestInvestigationStore() + investigation := investigations.Create(finding.ID, "session-1") + if investigation.ID != "inv-1" { + t.Fatalf("investigation id = %q, journey proposal expects inv-1", investigation.ID) + } + aiHandler.investigationStores = map[string]aicontracts.InvestigationStore{"default": investigations} + aiHandler.SetResourceStoreProvider(resources.getStore) + resources.SetActionTransitionPublisher(aiHandler.ReconcilePatrolActionTransition) + + disposition, err := NewPatrolActionBroker("default", resources).Submit(context.Background(), patrolTestProposal()) + if err != nil { + t.Fatalf("Submit: %v", err) + } + planned := investigations.Get(investigation.ID) + if planned.Action == nil || planned.Action.ActionID != disposition.ActionID || planned.Action.State != string(unified.ActionStatePending) { + t.Fatalf("planned investigation action = %#v", planned.Action) + } + if planned.Outcome != aicontracts.OutcomeFixQueued { + t.Fatalf("planned outcome = %q", planned.Outcome) + } + + decisionRec := httptest.NewRecorder() + decisionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+disposition.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved","reason":"maintenance window"}`)) + decisionReq.SetPathValue("id", disposition.ActionID) + decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) + resources.HandleDecideAction(decisionRec, decisionReq) + if decisionRec.Code != http.StatusOK { + t.Fatalf("decision status = %d body=%s", decisionRec.Code, decisionRec.Body.String()) + } + + executionRec := httptest.NewRecorder() + executionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+disposition.ActionID+"/execute", bytes.NewBufferString(`{"reason":"approved maintenance window"}`)) + executionReq.SetPathValue("id", disposition.ActionID) + executionReq = executionReq.WithContext(auth.WithUser(executionReq.Context(), "operator@example.com")) + resources.HandleExecuteAction(executionRec, executionReq) + if executionRec.Code != http.StatusOK { + t.Fatalf("execution status = %d body=%s", executionRec.Code, executionRec.Body.String()) + } + + completed := investigations.Get(investigation.ID) + if completed.Action == nil || completed.Action.State != string(unified.ActionStateCompleted) { + t.Fatalf("completed investigation action = %#v", completed.Action) + } + if completed.Outcome != aicontracts.OutcomeFixVerified { + t.Fatalf("completed outcome = %q, want verified", completed.Outcome) + } + updatedFinding := patrol.GetFindings().Get(finding.ID) + if updatedFinding == nil || updatedFinding.InvestigationOutcome != string(aicontracts.OutcomeFixVerified) || updatedFinding.ResolvedAt == nil { + t.Fatalf("reconciled finding = %#v", updatedFinding) + } + store, err := resources.getStore("default") + if err != nil { + t.Fatalf("getStore: %v", err) + } + audit, found, err := store.GetActionAudit(disposition.ActionID) + if err != nil || !found || audit.VerificationOutcome.Status != unified.VerificationVerified { + t.Fatalf("terminal audit: found=%v err=%v audit=%#v", found, err, audit) + } + if executor.calls != 1 { + t.Fatalf("executor calls = %d, want exactly one", executor.calls) + } + select { + case push := <-pushes: + if push.ActionType != relay.PushActionViewFixResult || push.ActionID != finding.ID || push.Body != "Action completed and verified" { + t.Fatalf("terminal push = %#v", push) + } + default: + t.Fatal("verified lifecycle did not publish a terminal mobile notification") + } +} diff --git a/internal/api/patrol_action_reconciliation.go b/internal/api/patrol_action_reconciliation.go new file mode 100644 index 000000000..593de2390 --- /dev/null +++ b/internal/api/patrol_action_reconciliation.go @@ -0,0 +1,165 @@ +package api + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" + "github.com/rs/zerolog/log" +) + +// ReconcilePatrolActionTransition projects the authoritative canonical action +// audit onto the Patrol investigation and finding that originated it. The +// callback payload is only a wake-up signal: re-reading by action ID prevents +// duplicate or out-of-order publications from regressing a surface record. +func (h *AISettingsHandler) ReconcilePatrolActionTransition(orgID string, transition unifiedresources.ActionAuditRecord) { + if h == nil || !isPatrolActionOrigin(transition.Origin) { + return + } + store, err := h.actionAuditStore(orgID) + if err != nil { + log.Warn().Err(err).Str("orgID", orgID).Str("actionID", transition.ID).Msg("Unable to reconcile Patrol action transition") + return + } + authoritative, found, err := store.GetActionAudit(transition.ID) + if err != nil { + log.Warn().Err(err).Str("orgID", orgID).Str("actionID", transition.ID).Msg("Unable to read authoritative Patrol action transition") + return + } + if !found || !isPatrolActionOrigin(authoritative.Origin) { + return + } + h.applyPatrolActionAudit(orgID, authoritative) +} + +// hydratePatrolInvestigationAction repairs a missed transition callback before +// an investigation is returned. Action audit remains authoritative: existing +// references hydrate by action ID, while older/missed records recover through +// the broker-owned origin index. +func (h *AISettingsHandler) hydratePatrolInvestigationAction(orgID string, investigation *aicontracts.InvestigationSession) *aicontracts.InvestigationSession { + if h == nil || investigation == nil { + return investigation + } + store, err := h.actionAuditStore(orgID) + if err != nil { + log.Warn().Err(err).Str("orgID", orgID).Str("investigationID", investigation.ID).Msg("Unable to hydrate Patrol action") + return investigation + } + + var audit unifiedresources.ActionAuditRecord + var found bool + if investigation.Action != nil && strings.TrimSpace(investigation.Action.ActionID) != "" { + audit, found, err = store.GetActionAudit(investigation.Action.ActionID) + } else if reader, ok := store.(unifiedresources.ActionAuditOriginReader); ok { + audit, found, err = reader.GetLatestActionAuditByOrigin(patrolActionOriginSurface, investigation.ID) + } + if err != nil { + log.Warn().Err(err).Str("orgID", orgID).Str("investigationID", investigation.ID).Msg("Unable to query Patrol action for hydration") + return investigation + } + if !found || !isPatrolActionOrigin(audit.Origin) || audit.Origin.InvestigationID != investigation.ID || audit.Origin.FindingID != investigation.FindingID { + return investigation + } + + h.applyPatrolActionAudit(orgID, audit) + // Return the hydrated projection even if the backing investigation store + // disappeared between lookup and repair. + copy := *investigation + copy.Action = actionReferenceFromAudit(audit) + if outcome := patrolOutcomeForActionAudit(audit); outcome != "" { + copy.Outcome = outcome + } + return © +} + +func (h *AISettingsHandler) applyPatrolActionAudit(orgID string, audit unifiedresources.ActionAuditRecord) { + origin := audit.Origin + if !isPatrolActionOrigin(origin) { + return + } + h.investigationMu.RLock() + store := h.investigationStores[orgID] + h.investigationMu.RUnlock() + if store == nil { + return + } + investigation := store.Get(origin.InvestigationID) + if investigation == nil || strings.TrimSpace(investigation.FindingID) != origin.FindingID { + return + } + reference := actionReferenceFromAudit(audit) + changed := false + if !reflect.DeepEqual(investigation.Action, reference) { + investigation.Action = reference + changed = true + } + if outcome := patrolOutcomeForActionAudit(audit); outcome != "" { + if investigation.Outcome != outcome { + investigation.Outcome = outcome + changed = true + } + if changed { + store.Update(investigation) + } + ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID) + h.updateFindingOutcome(ctx, orgID, origin.FindingID, string(outcome)) + return + } + if changed { + store.Update(investigation) + } +} + +func (h *AISettingsHandler) actionAuditStore(orgID string) (unifiedresources.ResourceStore, error) { + h.stateMu.RLock() + provider := h.resourceStoreProvider + h.stateMu.RUnlock() + if provider == nil { + return nil, fmt.Errorf("action audit store unavailable") + } + return provider(strings.TrimSpace(orgID)) +} + +func isPatrolActionOrigin(origin *unifiedresources.ActionOrigin) bool { + return origin != nil && + strings.TrimSpace(origin.Surface) == patrolActionOriginSurface && + strings.TrimSpace(origin.FindingID) != "" && + strings.TrimSpace(origin.InvestigationID) != "" +} + +func actionReferenceFromAudit(audit unifiedresources.ActionAuditRecord) *aicontracts.ActionReference { + plan := approvalPlanRequestToInfo(&audit.Plan) + return &aicontracts.ActionReference{ + ActionID: audit.ID, + ProposalID: audit.Origin.ProposalID, + ResourceID: audit.Request.ResourceID, + CapabilityName: audit.Request.CapabilityName, + State: string(audit.State), + Plan: *plan, + } +} + +func patrolOutcomeForActionAudit(audit unifiedresources.ActionAuditRecord) aicontracts.InvestigationOutcome { + switch audit.State { + case unifiedresources.ActionStatePlanned, unifiedresources.ActionStatePending, unifiedresources.ActionStateApproved, unifiedresources.ActionStateExecuting: + return aicontracts.OutcomeFixQueued + case unifiedresources.ActionStateRejected: + return aicontracts.OutcomeFixRejected + case unifiedresources.ActionStateFailed: + return aicontracts.OutcomeFixFailed + case unifiedresources.ActionStateCompleted: + switch unifiedresources.NormalizeVerificationOutcome(audit.VerificationOutcome).Status { + case unifiedresources.VerificationVerified: + return aicontracts.OutcomeFixVerified + case unifiedresources.VerificationFailed: + return aicontracts.OutcomeFixVerificationFailed + default: + return aicontracts.OutcomeFixVerificationUnknown + } + default: + return "" + } +} diff --git a/internal/api/relay_mobile_capability.go b/internal/api/relay_mobile_capability.go index 88ddb7e27..25464048c 100644 --- a/internal/api/relay_mobile_capability.go +++ b/internal/api/relay_mobile_capability.go @@ -29,6 +29,9 @@ const ( relayMobileRouteApprovalsList relayMobileRuntimeRouteID = "approvals-list" relayMobileRouteApprovalApprove relayMobileRuntimeRouteID = "approval-approve" relayMobileRouteApprovalDeny relayMobileRuntimeRouteID = "approval-deny" + relayMobileRoutePendingActions relayMobileRuntimeRouteID = "pending-actions" + relayMobileRouteActionDecision relayMobileRuntimeRouteID = "action-decision" + relayMobileRouteActionExecute relayMobileRuntimeRouteID = "action-execute" relayMobileRouteChatSend relayMobileRuntimeRouteID = "chat-send" relayMobileRouteSessionsList relayMobileRuntimeRouteID = "sessions-list" relayMobileRouteSessionCreate relayMobileRuntimeRouteID = "session-create" @@ -51,6 +54,9 @@ var relayMobileRuntimeRouteOrder = []relayMobileRuntimeRouteID{ relayMobileRouteApprovalsList, relayMobileRouteApprovalApprove, relayMobileRouteApprovalDeny, + relayMobileRoutePendingActions, + relayMobileRouteActionDecision, + relayMobileRouteActionExecute, relayMobileRouteChatSend, relayMobileRouteSessionsList, relayMobileRouteSessionCreate, @@ -133,6 +139,24 @@ var relayMobileRuntimeRouteSpecs = map[relayMobileRuntimeRouteID]relayMobileRunt path: "/api/ai/approvals/{approval_id}/deny", requiredScope: config.ScopeAIExecute, }, + relayMobileRoutePendingActions: { + id: relayMobileRoutePendingActions, + method: http.MethodGet, + path: "/api/actions/pending", + requiredScope: config.ScopeAIExecute, + }, + relayMobileRouteActionDecision: { + id: relayMobileRouteActionDecision, + method: http.MethodPost, + path: "/api/actions/{action_id}/decision", + requiredScope: config.ScopeAIExecute, + }, + relayMobileRouteActionExecute: { + id: relayMobileRouteActionExecute, + method: http.MethodPost, + path: "/api/actions/{action_id}/execute", + requiredScope: config.ScopeAIExecute, + }, relayMobileRouteChatSend: { id: relayMobileRouteChatSend, method: http.MethodPost, diff --git a/internal/api/relay_mobile_capability_test.go b/internal/api/relay_mobile_capability_test.go index 09ae073e1..464268ea3 100644 --- a/internal/api/relay_mobile_capability_test.go +++ b/internal/api/relay_mobile_capability_test.go @@ -51,6 +51,9 @@ func TestRelayMobileRuntimeRouteInventory(t *testing.T) { "GET /api/ai/approvals => ai:execute", "POST /api/ai/approvals/{approval_id}/approve => ai:execute", "POST /api/ai/approvals/{approval_id}/deny => ai:execute", + "GET /api/actions/pending => ai:execute", + "POST /api/actions/{action_id}/decision => ai:execute", + "POST /api/actions/{action_id}/execute => ai:execute", "POST /api/ai/chat => ai:chat", "GET /api/ai/sessions => ai:chat", "POST /api/ai/sessions => ai:chat", diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index a9d376757..e8feca4c4 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -429,6 +429,7 @@ var allRouteAllowlist = []string{ "/api/agent/capabilities", "/api/agent/events", "POST /api/actions/plan", + "GET /api/actions/pending", "POST /api/actions/{id}/decision", "POST /api/actions/{id}/execute", "/api/guests/metadata", diff --git a/internal/api/router.go b/internal/api/router.go index 4984caaa4..9bdec86e1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -658,6 +658,7 @@ func (r *Router) setupRoutes() { r.aiSettingsHandler = NewAISettingsHandler(r.multiTenant, r.mtMonitor, r.agentExecServer) if r.resourceHandlers != nil { r.aiSettingsHandler.SetResourceStoreProvider(r.resourceHandlers.getStore) + r.resourceHandlers.SetActionTransitionPublisher(r.aiSettingsHandler.ReconcilePatrolActionTransition) resourceHandlers := r.resourceHandlers r.aiSettingsHandler.SetActionBrokerFactory(func(orgID string) aicontracts.OrchestratorActionBroker { return NewPatrolActionBroker(orgID, resourceHandlers) diff --git a/internal/api/router_routes_ai_relay.go b/internal/api/router_routes_ai_relay.go index 84f2b77e4..ee2934239 100644 --- a/internal/api/router_routes_ai_relay.go +++ b/internal/api/router_routes_ai_relay.go @@ -584,6 +584,7 @@ func approvalPlanRequestToInfo(plan *unifiedresources.ActionPlan) *aicontracts.A ResourceVersion: plan.ResourceVersion, PolicyVersion: plan.PolicyVersion, PlanHash: plan.PlanHash, + Preflight: preflightRequestToInfo(plan.Preflight), } } @@ -605,6 +606,7 @@ func approvalPlanInfoToRequest(plan *aicontracts.ActionPlanInfo) *unifiedresourc ResourceVersion: plan.ResourceVersion, PolicyVersion: plan.PolicyVersion, PlanHash: plan.PlanHash, + Preflight: preflightInfoToRequest(plan.Preflight), } } diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 961cb2c7d..070278e56 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -140,11 +140,14 @@ func (r *Router) registerMonitoringResourceRoutes( agentcapabilities.PlanActionCapabilityName, r.resourceHandlers.HandlePlanAction, )))) - r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.withExternalAgentCapabilityActivity( + r.mux.HandleFunc("GET /api/actions/pending", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePendingActions, + r.resourceHandlers.HandleListPendingActions, + ))) + r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDecision, r.withExternalAgentCapabilityActivity( agentcapabilities.DecideActionCapabilityName, r.resourceHandlers.HandleDecideAction, )))) - r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.withExternalAgentCapabilityActivity( + r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionExecute, r.withExternalAgentCapabilityActivity( agentcapabilities.ExecuteActionCapabilityName, r.resourceHandlers.HandleExecuteAction, )))) diff --git a/internal/relay/protocol.go b/internal/relay/protocol.go index 3b4c993b1..098674844 100644 --- a/internal/relay/protocol.go +++ b/internal/relay/protocol.go @@ -195,7 +195,7 @@ type PushNotificationPayload struct { Title string `json:"title"` // Short title (≤100 chars) Body string `json:"body"` // Body text (≤200 chars) InstanceID string `json:"instance_id,omitempty"` // Canonical relay instance identifier for mobile routing/repair - ActionType string `json:"action_type,omitempty"` // "view_finding", "approve_fix", "view_fix_result" + ActionType string `json:"action_type,omitempty"` // "view_finding", "decide_action", legacy "approve_fix", "view_fix_result" ActionID string `json:"action_id,omitempty"` // Finding ID or Approval ID Category string `json:"category,omitempty"` // Finding category (performance, capacity, etc.) Severity string `json:"severity,omitempty"` // Finding severity diff --git a/internal/relay/push.go b/internal/relay/push.go index 30c2fc79d..9987b2ad5 100644 --- a/internal/relay/push.go +++ b/internal/relay/push.go @@ -24,6 +24,7 @@ const ( const ( PushActionViewFinding = "view_finding" PushActionApproveFix = "approve_fix" + PushActionDecideAction = "decide_action" PushActionViewFixResult = "view_fix_result" ) @@ -122,6 +123,20 @@ func NewApprovalRequestNotification(approvalID, findingTitle, riskLevel string) } } +// NewActionDecisionNotification creates a push notification for a canonical +// typed action awaiting an operator decision. ActionID is the lifecycle audit +// identity consumed by /api/actions/{id}; it is never a legacy approval ID. +func NewActionDecisionNotification(actionID, findingTitle string) PushNotificationPayload { + return PushNotificationPayload{ + Type: PushTypeApprovalRequest, + Priority: PushPriorityHigh, + Title: truncate(sanitizeTitle(findingTitle), maxPushTitleLen), + Body: "A proposed action requires your approval", + ActionType: PushActionDecideAction, + ActionID: actionID, + } +} + // NewFixCompletedNotification creates a push notification for a completed fix. func NewFixCompletedNotification(findingID, title string, success bool) PushNotificationPayload { body := "Fix applied successfully" @@ -138,3 +153,30 @@ func NewFixCompletedNotification(findingID, title string, success bool) PushNoti ActionID: findingID, } } + +// NewActionOutcomeNotification reports the verified lifecycle truth of a +// canonical typed action without calling an inconclusive outcome successful or +// failed. The finding remains the mobile destination because it owns the +// operator-facing investigation and action reference. +func NewActionOutcomeNotification(findingID, title, verificationStatus string) PushNotificationPayload { + body := "Action completed; verification was inconclusive" + priority := PushPriorityNormal + switch verificationStatus { + case "verified": + body = "Action completed and verified" + case "failed": + body = "Action completed, but verification failed" + priority = PushPriorityHigh + case "execution_failed": + body = "Action failed before verification" + priority = PushPriorityHigh + } + return PushNotificationPayload{ + Type: PushTypeFixCompleted, + Priority: priority, + Title: truncate(sanitizeTitle(title), maxPushTitleLen), + Body: truncate(body, maxPushBodyLen), + ActionType: PushActionViewFixResult, + ActionID: findingID, + } +} diff --git a/internal/relay/push_test.go b/internal/relay/push_test.go index db041c0dc..faec88132 100644 --- a/internal/relay/push_test.go +++ b/internal/relay/push_test.go @@ -74,6 +74,22 @@ func TestNewApprovalRequestNotification(t *testing.T) { }) } +func TestNewActionDecisionNotificationUsesCanonicalActionIdentity(t *testing.T) { + n := NewActionDecisionNotification("act-123", "Restart unhealthy workload") + if n.Type != PushTypeApprovalRequest || n.Priority != PushPriorityHigh { + t.Fatalf("notification posture = %#v", n) + } + if n.ActionType != PushActionDecideAction { + t.Fatalf("ActionType = %q, want %q", n.ActionType, PushActionDecideAction) + } + if n.ActionID != "act-123" { + t.Fatalf("ActionID = %q, want canonical action id", n.ActionID) + } + if strings.Contains(n.Body, "fix") { + t.Fatalf("typed action notification must not claim a command-shaped fix: %q", n.Body) + } +} + func TestNewFixCompletedNotification(t *testing.T) { t.Run("success", func(t *testing.T) { n := NewFixCompletedNotification("finding-100", "Cleared disk space", true) @@ -104,6 +120,23 @@ func TestNewFixCompletedNotification(t *testing.T) { }) } +func TestNewActionOutcomeNotificationIsHonestAboutVerification(t *testing.T) { + for _, tc := range []struct { + status string + body string + }{ + {status: "verified", body: "Action completed and verified"}, + {status: "unverified", body: "Action completed; verification was inconclusive"}, + {status: "failed", body: "Action completed, but verification failed"}, + {status: "execution_failed", body: "Action failed before verification"}, + } { + n := NewActionOutcomeNotification("finding-1", "Workload action", tc.status) + if n.Body != tc.body || n.ActionType != PushActionViewFixResult || n.ActionID != "finding-1" { + t.Fatalf("status %q notification = %#v", tc.status, n) + } + } +} + func TestNotificationTruncation(t *testing.T) { longTitle := strings.Repeat("A", 200) n := NewPatrolFindingNotification("id", "warning", "capacity", longTitle) diff --git a/internal/unifiedresources/actions.go b/internal/unifiedresources/actions.go index c648bef74..163f2d6a6 100644 --- a/internal/unifiedresources/actions.go +++ b/internal/unifiedresources/actions.go @@ -171,6 +171,24 @@ func NormalizeVerificationOutcome(outcome VerificationOutcome) VerificationOutco return outcome } +// VerificationOutcomeFromExecutionResult derives the durable lifecycle +// classification from the executor's read-after-write result at the single +// completion boundary. Executors report evidence; they do not get to write a +// separate audit classification that can drift from it. +func VerificationOutcomeFromExecutionResult(result *ExecutionResult) VerificationOutcome { + if result == nil || !result.Success || result.Verification == nil { + return VerificationOutcome{Status: VerificationUnknown} + } + verification := NormalizeActionVerificationResult(result.Verification) + if verification == nil || !verification.Ran { + return VerificationOutcome{Status: VerificationUnverified, EvidenceSummary: strings.TrimSpace(verification.Note)} + } + if verification.Success { + return VerificationOutcome{Status: VerificationVerified, EvidenceSummary: strings.TrimSpace(verification.Note)} + } + return VerificationOutcome{Status: VerificationFailed, EvidenceSummary: strings.TrimSpace(verification.Note)} +} + // NormalizeActionVerificationResult applies the canonical verification field // hygiene used by stored audit records and every action-audit projection. func NormalizeActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult { @@ -436,6 +454,7 @@ func CompleteActionExecution(record ActionAuditRecord, result *ExecutionResult, record.State = nextState record.UpdatedAt = now record.Result = result + record.VerificationOutcome = VerificationOutcomeFromExecutionResult(result) normalized, err := NormalizeActionAuditRecord(record) if err != nil { return ActionAuditRecord{}, ActionLifecycleEvent{}, err diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index aed6cc01a..517b2fada 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sync" "time" @@ -73,6 +74,21 @@ type ResourceStore interface { Close() error } +// ActionAuditOriginReader is the optional origin-indexed lookup used by +// proposing surfaces to repair missed transition callbacks at read time. It is +// separate from ResourceStore so external/test stores that do not support +// broker-origin recovery remain source-compatible. +type ActionAuditOriginReader interface { + GetLatestActionAuditByOrigin(surface, investigationID string) (ActionAuditRecord, bool, error) +} + +// PendingActionAuditReader owns the indexed operator queue for canonical +// actions awaiting a decision. Mobile and desktop clients must not reconstruct +// this queue from the retired command-approval store. +type PendingActionAuditReader interface { + GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) +} + // ResourceLink represents a manual merge. type ResourceLink struct { ResourceA string @@ -433,7 +449,6 @@ func (s *SQLiteResourceStore) initSchema() error { ); CREATE INDEX IF NOT EXISTS idx_action_audits_canonical_created ON action_audits(canonical_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_action_audits_action_id ON action_audits(action_id); - CREATE TABLE IF NOT EXISTS action_lifecycle_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, action_id TEXT NOT NULL, @@ -571,6 +586,19 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error { return fmt.Errorf("add action_audits.origin_json column: %w", err) } } + if _, err := s.db.Exec(` + CREATE INDEX IF NOT EXISTS idx_action_audits_origin_investigation_updated_v2 + ON action_audits(json_extract(origin_json, '$.surface'), json_extract(origin_json, '$.investigationId'), updated_at DESC) + WHERE json_valid(origin_json) + `); err != nil { + return fmt.Errorf("create action audit origin investigation index: %w", err) + } + if _, err := s.db.Exec(` + CREATE INDEX IF NOT EXISTS idx_action_audits_state_updated + ON action_audits(state, updated_at ASC, created_at ASC) + `); err != nil { + return fmt.Errorf("create action audit state index: %w", err) + } return nil } @@ -1681,7 +1709,7 @@ func recordActionAuditSQL(exec sqlExecutor, record ActionAuditRecord) error { if err != nil { return fmt.Errorf("marshal verification outcome: %w", err) } - originJSON := "" + var originJSON any if origin := NormalizeActionOrigin(record.Origin); origin != nil { encoded, err := json.Marshal(origin) if err != nil { @@ -1821,6 +1849,60 @@ func (s *SQLiteResourceStore) GetActionAudit(actionID string) (ActionAuditRecord return s.getActionAudit(actionID) } +func (s *SQLiteResourceStore) GetLatestActionAuditByOrigin(surface, investigationID string) (ActionAuditRecord, bool, error) { + surface = strings.TrimSpace(surface) + investigationID = strings.TrimSpace(investigationID) + if surface == "" || investigationID == "" { + return ActionAuditRecord{}, false, nil + } + row := s.db.QueryRow(` + SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + FROM action_audits + WHERE json_valid(origin_json) + AND json_extract(origin_json, '$.surface') = ? + AND json_extract(origin_json, '$.investigationId') = ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `, surface, investigationID) + record, err := scanActionAuditRecord(row) + if errors.Is(err, sql.ErrNoRows) { + return ActionAuditRecord{}, false, nil + } + if err != nil { + return ActionAuditRecord{}, false, fmt.Errorf("query action audit by origin: %w", err) + } + return record, true, nil +} + +func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.Query(` + SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + FROM action_audits + WHERE state = ? + ORDER BY updated_at ASC, created_at ASC + LIMIT ? + `, ActionStatePending, limit) + if err != nil { + return nil, fmt.Errorf("query pending action audits: %w", err) + } + defer rows.Close() + records := make([]ActionAuditRecord, 0) + for rows.Next() { + record, err := scanActionAuditRecord(rows) + if err != nil { + return nil, fmt.Errorf("scan pending action audit row: %w", err) + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate pending action audit rows: %w", err) + } + return records, nil +} + func (s *SQLiteResourceStore) GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error) { query := ` SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json @@ -2865,6 +2947,52 @@ func (m *MemoryStore) GetActionAudit(actionID string) (ActionAuditRecord, bool, return ActionAuditRecord{}, false, nil } +func (m *MemoryStore) GetLatestActionAuditByOrigin(surface, investigationID string) (ActionAuditRecord, bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + surface = strings.TrimSpace(surface) + investigationID = strings.TrimSpace(investigationID) + if surface == "" || investigationID == "" { + return ActionAuditRecord{}, false, nil + } + var latest ActionAuditRecord + found := false + for _, record := range m.actionAudits { + if record.Origin == nil || strings.TrimSpace(record.Origin.Surface) != surface || strings.TrimSpace(record.Origin.InvestigationID) != investigationID { + continue + } + if !found || record.UpdatedAt.After(latest.UpdatedAt) || (record.UpdatedAt.Equal(latest.UpdatedAt) && record.CreatedAt.After(latest.CreatedAt)) { + latest = record + found = true + } + } + return latest, found, nil +} + +func (m *MemoryStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if limit <= 0 || limit > 500 { + limit = 100 + } + out := make([]ActionAuditRecord, 0) + for _, record := range m.actionAudits { + if record.State == ActionStatePending { + out = append(out, record) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].UpdatedAt.Equal(out[j].UpdatedAt) { + return out[i].CreatedAt.Before(out[j].CreatedAt) + } + return out[i].UpdatedAt.Before(out[j].UpdatedAt) + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + func (m *MemoryStore) RecordActionDecision(record ActionAuditRecord, event ActionLifecycleEvent) error { normalizedRecord, err := NormalizeActionAuditRecord(record) if err != nil { diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index f7460a4c3..9570b2252 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -400,6 +400,8 @@ func TestNewSQLiteResourceStore_InitializesCanonicalAuditSchemas(t *testing.T) { indexes: []string{ "idx_action_audits_canonical_created", "idx_action_audits_action_id", + "idx_action_audits_origin_investigation_updated_v2", + "idx_action_audits_state_updated", }, }, { @@ -2601,3 +2603,80 @@ func TestSQLiteStoreActionAuditOriginRoundTrip(t *testing.T) { t.Fatalf("empty origin should read back nil, got %#v", got.Origin) } } + +func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) { + constructors := []struct { + name string + new func(t *testing.T) ResourceStore + }{ + { + name: "sqlite", + new: func(t *testing.T) ResourceStore { + store, err := NewSQLiteResourceStore(t.TempDir(), "default") + if err != nil { + t.Fatalf("NewSQLiteResourceStore: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return store + }, + }, + {name: "memory", new: func(_ *testing.T) ResourceStore { return NewMemoryStore() }}, + } + for _, tc := range constructors { + t.Run(tc.name, func(t *testing.T) { + store := tc.new(t) + reader, ok := store.(ActionAuditOriginReader) + if !ok { + t.Fatalf("%T does not implement ActionAuditOriginReader", store) + } + now := time.Now().UTC() + for _, record := range []ActionAuditRecord{ + { + ID: "act-old", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), State: ActionStatePending, + Request: ActionRequest{RequestID: "prop-old", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"}, + Plan: ActionPlan{ActionID: "act-old", RequestID: "prop-old", Allowed: true}, + Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-old"}, + }, + { + ID: "act-new", CreatedAt: now, UpdatedAt: now, State: ActionStateCompleted, + Request: ActionRequest{RequestID: "prop-new", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"}, + Plan: ActionPlan{ActionID: "act-new", RequestID: "prop-new", Allowed: true}, + Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-new"}, + VerificationOutcome: VerificationOutcome{Status: VerificationVerified}, + }, + } { + if err := store.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit(%s): %v", record.ID, err) + } + } + got, found, err := reader.GetLatestActionAuditByOrigin("patrol", "inv-1") + if err != nil || !found { + t.Fatalf("GetLatestActionAuditByOrigin: found=%v err=%v", found, err) + } + if got.ID != "act-new" || got.State != ActionStateCompleted { + t.Fatalf("latest audit = %#v, want act-new completed", got) + } + }) + } +} + +func TestPendingActionAuditReaderReturnsOldestPendingFirst(t *testing.T) { + store := NewMemoryStore() + now := time.Now().UTC() + for _, record := range []ActionAuditRecord{ + {ID: "act-new", CreatedAt: now, UpdatedAt: now, State: ActionStatePending, Request: ActionRequest{RequestID: "new", ResourceID: "vm:2", CapabilityName: "restart", RequestedBy: "pulse_patrol"}, Plan: ActionPlan{ActionID: "act-new", RequestID: "new", Allowed: true}}, + {ID: "act-old", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), State: ActionStatePending, Request: ActionRequest{RequestID: "old", ResourceID: "vm:1", CapabilityName: "restart", RequestedBy: "pulse_patrol"}, Plan: ActionPlan{ActionID: "act-old", RequestID: "old", Allowed: true}}, + {ID: "act-done", CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), State: ActionStateCompleted, Request: ActionRequest{RequestID: "done", ResourceID: "vm:3", CapabilityName: "restart", RequestedBy: "pulse_patrol"}, Plan: ActionPlan{ActionID: "act-done", RequestID: "done", Allowed: true}}, + } { + if err := store.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit(%s): %v", record.ID, err) + } + } + got, err := store.GetPendingActionAudits(100) + if err != nil { + t.Fatalf("GetPendingActionAudits: %v", err) + } + if len(got) != 2 || got[0].ID != "act-old" || got[1].ID != "act-new" { + t.Fatalf("pending actions = %#v, want oldest pending first", got) + } +} diff --git a/internal/unifiedresources/verification_outcome_test.go b/internal/unifiedresources/verification_outcome_test.go index 556688461..3a9fe8a55 100644 --- a/internal/unifiedresources/verification_outcome_test.go +++ b/internal/unifiedresources/verification_outcome_test.go @@ -50,6 +50,27 @@ func TestNormalizeActionAuditRecordDefaultsVerificationOutcomeToUnknown(t *testi } } +func TestVerificationOutcomeFromExecutionResult(t *testing.T) { + for _, tc := range []struct { + name string + result *ExecutionResult + want VerificationStatus + }{ + {name: "no result", result: nil, want: VerificationUnknown}, + {name: "execution failed", result: &ExecutionResult{Success: false}, want: VerificationUnknown}, + {name: "no verifier", result: &ExecutionResult{Success: true}, want: VerificationUnknown}, + {name: "inconclusive", result: &ExecutionResult{Success: true, Verification: &ActionVerificationResult{Ran: false, Note: "agent unreachable"}}, want: VerificationUnverified}, + {name: "verified", result: &ExecutionResult{Success: true, Verification: &ActionVerificationResult{Ran: true, Success: true, Note: "healthy"}}, want: VerificationVerified}, + {name: "postcondition failed", result: &ExecutionResult{Success: true, Verification: &ActionVerificationResult{Ran: true, Success: false, Note: "still degraded"}}, want: VerificationFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := VerificationOutcomeFromExecutionResult(tc.result); got.Status != tc.want { + t.Fatalf("status = %q, want %q", got.Status, tc.want) + } + }) + } +} + // TestActionAuditRecordJSONRoundtripWithMissingVerificationOutcome simulates an // older record persisted before the verifier substrate was added: the JSON // blob omits the verificationOutcome field. After unmarshal + normalize, the diff --git a/pkg/aicontracts/action_broker.go b/pkg/aicontracts/action_broker.go index 8cd5eb54a..784f869de 100644 --- a/pkg/aicontracts/action_broker.go +++ b/pkg/aicontracts/action_broker.go @@ -88,6 +88,23 @@ type ActionReference struct { Plan ActionPlanInfo `json:"plan"` } +// CloneActionReference returns an immutable deep copy suitable for crossing +// investigation-store and product-read-model boundaries. +func CloneActionReference(reference *ActionReference) *ActionReference { + if reference == nil { + return nil + } + clone := *reference + clone.Plan.PredictedBlastRadius = append([]string(nil), reference.Plan.PredictedBlastRadius...) + if reference.Plan.Preflight != nil { + preflight := *reference.Plan.Preflight + preflight.SafetyChecks = append([]string(nil), reference.Plan.Preflight.SafetyChecks...) + preflight.VerificationSteps = append([]string(nil), reference.Plan.Preflight.VerificationSteps...) + clone.Plan.Preflight = &preflight + } + return &clone +} + // ErrSensitiveParamsRequireOperator reports that a proposal populated a // parameter the capability declares sensitive. Such proposals stop for // operator input instead of persisting secret material. diff --git a/pkg/aicontracts/contracts_test.go b/pkg/aicontracts/contracts_test.go index 1d6c6e685..e52db609c 100644 --- a/pkg/aicontracts/contracts_test.go +++ b/pkg/aicontracts/contracts_test.go @@ -2,6 +2,7 @@ package aicontracts import ( "encoding/json" + "errors" "reflect" "strings" "testing" @@ -218,6 +219,27 @@ func TestActionReferenceIsAdditiveOnInvestigationShapes(t *testing.T) { } } +func TestOrchestratorInvestigationErrorPreservesBothFailureChannels(t *testing.T) { + runFailure := errors.New("provider unavailable") + err := NewOrchestratorInvestigationError(runFailure, ErrInvestigationProposalAmbiguous) + if !errors.Is(err, runFailure) { + t.Fatal("joined investigation error lost the runtime failure") + } + if !errors.Is(err, ErrInvestigationProposalAmbiguous) { + t.Fatal("joined investigation error lost the proposal failure") + } + var investigationErr *OrchestratorInvestigationError + if !errors.As(err, &investigationErr) { + t.Fatalf("error type = %T, want *OrchestratorInvestigationError", err) + } + if investigationErr.RunFailure() != runFailure { + t.Fatalf("RunFailure() = %v, want %v", investigationErr.RunFailure(), runFailure) + } + if investigationErr.ProposalFailure() != ErrInvestigationProposalAmbiguous { + t.Fatalf("ProposalFailure() = %v", investigationErr.ProposalFailure()) + } +} + func TestOrchestratorActionBrokerIsProposeOnly(t *testing.T) { // The broker seam must never grow decision or execution authority: // enterprise investigation code proposes, the canonical core diff --git a/pkg/aicontracts/fix_execution.go b/pkg/aicontracts/fix_execution.go index 4c20b6120..1c137f262 100644 --- a/pkg/aicontracts/fix_execution.go +++ b/pkg/aicontracts/fix_execution.go @@ -40,19 +40,20 @@ type ApprovalInfo struct { } type ActionPlanInfo struct { - ActionID string `json:"actionId,omitempty"` - RequestID string `json:"requestId,omitempty"` - Allowed bool `json:"allowed"` - RequiresApproval bool `json:"requiresApproval"` - ApprovalPolicy string `json:"approvalPolicy,omitempty"` - PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"` - RollbackAvailable bool `json:"rollbackAvailable"` - Message string `json:"message,omitempty"` - PlannedAt time.Time `json:"plannedAt,omitempty"` - ExpiresAt time.Time `json:"expiresAt,omitempty"` - ResourceVersion string `json:"resourceVersion,omitempty"` - PolicyVersion string `json:"policyVersion,omitempty"` - PlanHash string `json:"planHash,omitempty"` + ActionID string `json:"actionId,omitempty"` + RequestID string `json:"requestId,omitempty"` + Allowed bool `json:"allowed"` + RequiresApproval bool `json:"requiresApproval"` + ApprovalPolicy string `json:"approvalPolicy,omitempty"` + PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"` + RollbackAvailable bool `json:"rollbackAvailable"` + Message string `json:"message,omitempty"` + PlannedAt time.Time `json:"plannedAt,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` + PolicyVersion string `json:"policyVersion,omitempty"` + PlanHash string `json:"planHash,omitempty"` + Preflight *ActionPreflightInfo `json:"preflight,omitempty"` } type ContextConfidenceInfo struct { diff --git a/pkg/aicontracts/orchestrator_deps.go b/pkg/aicontracts/orchestrator_deps.go index 824465a34..c5ae99837 100644 --- a/pkg/aicontracts/orchestrator_deps.go +++ b/pkg/aicontracts/orchestrator_deps.go @@ -72,6 +72,58 @@ var ( ErrInvestigationProposalAttemptsFailed = errors.New("investigation made proposal attempts but none validated") ) +// OrchestratorInvestigationError preserves the independent runtime and +// proposal-channel failures across the Pulse/Enterprise boundary. A proposal +// failure may be handled as a completed needs-attention outcome only when +// RunFailure is nil. +type OrchestratorInvestigationError struct { + runFailure error + proposalFailure error +} + +// NewOrchestratorInvestigationError constructs the public cross-repo failure +// without exposing mutable error fields. +func NewOrchestratorInvestigationError(runFailure, proposalFailure error) error { + if runFailure == nil && proposalFailure == nil { + return nil + } + return &OrchestratorInvestigationError{ + runFailure: runFailure, + proposalFailure: proposalFailure, + } +} + +func (e *OrchestratorInvestigationError) Error() string { + if e == nil { + return "" + } + return errors.Join(e.runFailure, e.proposalFailure).Error() +} + +// Unwrap preserves errors.Is/errors.As behavior for both failure channels. +func (e *OrchestratorInvestigationError) Unwrap() []error { + if e == nil { + return nil + } + return []error{e.runFailure, e.proposalFailure} +} + +// RunFailure returns the provider/runtime failure, if any. +func (e *OrchestratorInvestigationError) RunFailure() error { + if e == nil { + return nil + } + return e.runFailure +} + +// ProposalFailure returns the proposal-channel failure, if any. +func (e *OrchestratorInvestigationError) ProposalFailure() error { + if e == nil { + return nil + } + return e.proposalFailure +} + // OrchestratorFindingsStore provides access to patrol findings for the orchestrator. type OrchestratorFindingsStore interface { Get(id string) *Finding diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 1aca9abdd..35581380d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -1370,6 +1370,8 @@ class SubsystemLookupTest(unittest.TestCase): "internal/relay/client_test.go", "internal/relay/config_env_test.go", "internal/relay/encryption_test.go", + "internal/relay/protocol_test.go", + "internal/relay/push_test.go", ], )