Complete Patrol action lifecycle continuity

This commit is contained in:
rcourtman
2026-07-10 21:22:06 +01:00
parent 3bea52b1b5
commit 5bbfce956d
53 changed files with 1943 additions and 982 deletions
+51 -2
View File
@@ -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": [
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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"
]
},
{
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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: [
+4 -5
View File
@@ -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;
}
+4 -11
View File
@@ -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 {
@@ -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<PendingActionsResponse> {
return apiFetchJSON<PendingActionsResponse>('/api/actions/pending');
}
static async planAction(request: ResourceActionRequest): Promise<ActionAuditPlan> {
return apiFetchJSON<ActionAuditPlan>('/api/actions/plan', {
method: 'POST',
@@ -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<ApprovalSectionProps> = (props) => {
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
const [executionResult, setExecutionResult] = createSignal<ApprovalExecutionResult | null>(null);
const [busyAction, setBusyAction] = createSignal<string | null>(null);
const [latestAudit, setLatestAudit] = createSignal<ActionAuditRecord | null>(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<ApprovalSectionProps> = (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<PatrolActionReference | null>(() => {
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 (
<details class="rounded border border-border bg-surface-alt px-2 py-1.5 text-xs">
<summary class="cursor-pointer font-medium text-muted">Technical details</summary>
<div class="mt-2 space-y-1">
<For each={commandList}>
{(command) => (
<code class="block break-all rounded bg-surface px-2 py-1 font-mono text-base-content">
{command}
</code>
)}
</For>
</div>
</details>
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) => (
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Show when={canAutoFix()}>
<Button
type="button"
variant="warningSolid"
size="sm"
onClick={handleReapprove}
disabled={actionLoading() === 'reapprove'}
class="flex-1 gap-1.5"
>
<Show when={actionLoading() === 'reapprove'}>
<LoadingSpinner size="sm" tone="inverse" />
</Show>
<Show when={actionLoading() !== 'reapprove'}>
<CheckIcon class="w-3.5 h-3.5" />
</Show>
Re-approve fix
</Button>
</Show>
<Show when={!canAutoFix()}>
<Button
type="button"
variant="primary"
size="sm"
onClick={onAssistantClick}
class="flex-1 gap-1.5"
>
<MessageSquareIcon class="w-3.5 h-3.5" />
{assistantLabel}
</Button>
</Show>
</div>
);
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 (
<Show when={shouldShow()}>
<div class="mt-3 pt-3 border-t border-border-subtle">
{/* Pending approval */}
<Show when={pendingApproval() && !executionResult()}>
{(() => {
const approval = pendingApproval()!;
const approvalRisk = getApprovalRiskPresentation(approval.riskLevel);
return (
<>
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="text-sm font-medium text-base-content">Fix Available</span>
<MetadataBadge {...APPROVAL_SECTION_BADGE_PROPS} tone={approvalRisk.badgeTone}>
{approvalRisk.label} risk
</MetadataBadge>
</div>
<div class="space-y-2 text-sm">
<div class="text-muted">{approval.context}</div>
<Show when={approval.targetName}>
<div class="text-xs text-muted">Target: {approval.targetName}</div>
</Show>
{renderTechnicalCommandDetails(approval.command)}
</div>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Show when={canAutoFix()}>
<Button
type="button"
variant="success"
size="sm"
onClick={(e) => handleApprove(approval, e)}
disabled={actionLoading() === approval.id}
class="flex-1 gap-1.5"
>
<Show when={actionLoading() === approval.id}>
<LoadingSpinner size="sm" tone="inverse" />
</Show>
<Show when={actionLoading() !== approval.id}>
<CheckIcon class="w-3.5 h-3.5" />
</Show>
Approve fix
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => handleDeny(approval, e)}
disabled={actionLoading() === approval.id}
class="text-muted"
>
Reject
</Button>
</Show>
<Show when={!canAutoFix()}>
<Button
type="button"
variant="primary"
size="sm"
onClick={(e) => handleFixWithAssistant(approval, null, e)}
class="flex-1 gap-1.5"
>
<MessageSquareIcon class="w-3.5 h-3.5" />
Fix with Assistant
</Button>
</Show>
</div>
</>
);
})()}
</Show>
{/* Expired approval - show re-approve */}
<Show when={isExpired() && !executionResult()}>
{(() => {
const fix = investigation()!.proposed_fix!;
const fixRisk = getApprovalRiskPresentation(fix.risk_level);
return (
<>
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span class="text-sm font-medium text-base-content">Fix Pending Approval</span>
<MetadataBadge {...APPROVAL_SECTION_BADGE_PROPS} tone={fixRisk.badgeTone}>
{fixRisk.label} risk
</MetadataBadge>
<MetadataBadge {...APPROVAL_SECTION_BADGE_PROPS} tone="warning">
approval expired
</MetadataBadge>
</div>
<div class="space-y-2 text-sm">
<div class="text-muted">{fix.description}</div>
<Show when={fix.target_host}>
<div class="text-xs text-muted">Target: {fix.target_host}</div>
</Show>
{renderTechnicalCommandDetails(fix.commands)}
</div>
{renderRecoveryActions('Fix with Assistant', (e) =>
handleFixWithAssistant(null, fix, e),
)}
</>
);
})()}
</Show>
{/* Queued approval with missing detail payload - keep recovery path visible */}
<Show when={isQueuedWithoutDetails() && !executionResult()}>
<>
<div class="flex items-center gap-2 mb-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span class="text-sm font-medium text-base-content">Fix Pending Approval</span>
<MetadataBadge {...APPROVAL_SECTION_BADGE_PROPS} tone="warning">
details unavailable
</MetadataBadge>
<div class="mt-3 border-t border-border-subtle pt-3">
<Show
when={!investigation.loading}
fallback={
<div class="flex items-center gap-2 text-sm text-muted">
<LoadingSpinner size="sm" />
Loading governed action
</div>
<div class="space-y-2 text-sm">
<div class="text-muted">
Patrol queued a fix for this finding, but the original approval details are no
longer available.
}
>
<Show
when={action()}
fallback={
<div class="space-y-3">
<div>
<div class="text-sm font-medium text-base-content">
Action details unavailable
</div>
<div class="mt-1 text-sm text-muted">
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.
</div>
</div>
<Button type="button" variant="primary" size="sm" onClick={handleDiscuss}>
<MessageSquareIcon class="h-3.5 w-3.5" />
Discuss with Assistant
</Button>
</div>
<div class="text-xs text-muted">
Regenerate the approval to continue, or rerun the investigation to let Patrol
rebuild the remediation plan.
</div>
</div>
{renderRecoveryActions('Discuss with Assistant', handleDiscussQueuedFix)}
</>
</Show>
{/* Execution result */}
<Show when={executionResult()}>
<RemediationStatus result={executionResult()!} />
</Show>
{/* Executed (from backend state, no local result) */}
<Show when={isExecuted() && !executionResult()}>
<div
class={`flex items-center gap-2 ${isVerificationUnknown() ? 'text-amber-700 dark:text-amber-300' : 'text-green-600 dark:text-green-400'}`}
}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium">
{props.investigationOutcome === 'fix_verified'
? 'Fix verified — issue resolved'
: props.investigationOutcome === 'fix_verification_unknown'
? 'Fix executed — verification inconclusive'
: 'Fix executed successfully'}
</span>
</div>
<Show when={investigation()?.proposed_fix}>
{(fix) => (
<div class="mt-2 space-y-1 text-sm">
<div class="text-muted">{fix().description}</div>
<Show when={fix().target_host}>
<div class="text-xs text-muted">Target: {fix().target_host}</div>
</Show>
<Show when={fix().rationale}>
<div class="text-xs text-muted whitespace-pre-line mt-1">{fix().rationale}</div>
</Show>
{renderTechnicalCommandDetails(fix().commands)}
</div>
)}
</Show>
</Show>
{(currentAction) => {
const presentation = createMemo(() => statePresentation(currentAction().state));
const preflight = createMemo(() => currentAction().plan.preflight);
return (
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content">
{capabilityLabel(currentAction().capability_name)}
</span>
<MetadataBadge {...BADGE_PROPS} tone={presentation().tone}>
{presentation().label}
</MetadataBadge>
<Show when={currentAction().plan.rollbackAvailable}>
<MetadataBadge {...BADGE_PROPS} tone="neutral">
Rollback available
</MetadataBadge>
</Show>
</div>
{/* Rejected by operator */}
<Show when={isRejected() && !executionResult()}>
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<XIcon class="w-4 h-4" />
<span class="text-sm font-medium">Fix rejected before execution</span>
</div>
<Show when={investigation()?.proposed_fix}>
{(fix) => (
<div class="mt-2 space-y-1 text-sm">
<div class="text-muted">{fix().description}</div>
<Show when={fix().target_host}>
<div class="text-xs text-muted">Target: {fix().target_host}</div>
</Show>
</div>
)}
</Show>
{renderRecoveryActions('Discuss with Assistant', handleDiscussQueuedFix)}
</Show>
<div class="space-y-1 text-sm text-muted">
<div>
{currentAction().plan.message || 'Patrol proposed a governed action.'}
</div>
<div class="text-xs">
Target: {props.resourceName || currentAction().resource_id}
</div>
<Show when={preflight()?.intendedChange}>
<div class="text-xs">Change: {preflight()!.intendedChange}</div>
</Show>
<Show when={preflight()?.dryRunSummary}>
<div class="text-xs">Dry run: {preflight()!.dryRunSummary}</div>
</Show>
</div>
{/* Failed (from backend state, no local result) */}
<Show when={isFailed() && !executionResult()}>
<div class="flex items-center gap-2 text-red-600 dark:text-red-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium">
{props.investigationOutcome === 'fix_verification_failed'
? 'Fix executed but issue persists'
: 'Fix execution failed'}
</span>
</div>
<Show when={investigation()?.proposed_fix}>
{(fix) => (
<div class="mt-2 space-y-1 text-sm">
<div class="text-muted">{fix().description}</div>
<Show when={fix().target_host}>
<div class="text-xs text-muted">Target: {fix().target_host}</div>
</Show>
<Show when={fix().rationale}>
<div class="text-xs text-muted whitespace-pre-line mt-1">{fix().rationale}</div>
</Show>
{renderTechnicalCommandDetails(fix().commands)}
</div>
)}
<Show when={(preflight()?.safetyChecks?.length || 0) > 0}>
<details class="rounded border border-border bg-surface-alt px-2 py-1.5 text-xs">
<summary class="cursor-pointer font-medium text-muted">
Safety and verification
</summary>
<ul class="mt-2 list-disc space-y-1 pl-4 text-muted">
<For each={preflight()?.safetyChecks}>{(check) => <li>{check}</li>}</For>
<For each={preflight()?.verificationSteps}>{(step) => <li>{step}</li>}</For>
</ul>
</details>
</Show>
<Show when={currentAction().state === 'completed'}>
<div
class={`text-sm font-medium ${
verificationStatus() === 'verified'
? 'text-green-600 dark:text-green-400'
: 'text-amber-700 dark:text-amber-300'
}`}
>
{verificationStatus() === 'verified'
? 'Outcome verified'
: 'Execution finished; verification was not conclusive'}
</div>
</Show>
<Show when={currentAction().state === 'failed'}>
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{latestAudit()?.result?.errorMessage ||
'The action failed before verification.'}
</div>
</Show>
<div class="flex flex-wrap items-center gap-2 border-t border-border-subtle pt-3">
<Show when={canManageAction() && currentAction().state === 'pending_approval'}>
<Button
type="button"
variant="success"
size="sm"
onClick={handleApproveAndRun}
disabled={busyAction() !== null}
>
<Show
when={busyAction() === 'approve'}
fallback={<CheckIcon class="h-3.5 w-3.5" />}
>
<LoadingSpinner size="sm" tone="inverse" />
</Show>
Approve and run
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleReject}
disabled={busyAction() !== null}
>
<XIcon class="h-3.5 w-3.5" />
Reject
</Button>
</Show>
<Show
when={
canManageAction() &&
(currentAction().state === 'planned' ||
currentAction().state === 'approved')
}
>
<Button
type="button"
variant="success"
size="sm"
onClick={handleRun}
disabled={busyAction() !== null}
>
<Show
when={busyAction() === 'execute'}
fallback={<PlayIcon class="h-3.5 w-3.5" />}
>
<LoadingSpinner size="sm" tone="inverse" />
</Show>
Run action
</Button>
</Show>
<Button type="button" variant="ghost" size="sm" onClick={handleDiscuss}>
<MessageSquareIcon class="h-3.5 w-3.5" />
Discuss with Assistant
</Button>
</div>
</div>
);
}}
</Show>
</Show>
</div>
@@ -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<InvestigationSession | null>>());
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 }) => (
<div>{props.result.message}</div>
),
}));
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('<Button');
expect(approvalSectionSource).toContain('variant="success"');
expect(approvalSectionSource).toContain('variant="warningSolid"');
expect(approvalSectionSource).toContain('variant="primary"');
expect(approvalSectionSource).toContain('variant="ghost"');
expect(approvalSectionSource).not.toContain('px-3 py-1.5 bg-green-600 hover:bg-green-700');
expect(approvalSectionSource).not.toContain('px-3 py-1.5 bg-amber-600 hover:bg-amber-700');
expect(approvalSectionSource).not.toContain('px-3 py-1.5 bg-blue-600 hover:bg-blue-700');
expect(approvalSectionSource).not.toContain(
'px-3 py-1.5 hover:bg-surface-hover disabled:opacity-50 text-muted',
it('approves and executes a pending typed action through the canonical lifecycle', async () => {
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(() => <ApprovalSection findingId="finding-1" investigationOutcome="fix_queued" />);
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(() => <ApprovalSection findingId="finding-1" investigationOutcome="fix_queued" />);
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(() => <ApprovalSection findingId="finding-1" investigationOutcome="fix_queued" />);
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}</div>');
expect(approvalSectionSource).not.toContain('{fix.commands![0]}</div>');
expect(approvalSectionSource).not.toContain('{fix().commands![0]}</div>');
});
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(() => <ApprovalSection findingId="finding-1" investigationOutcome="fix_queued" />);
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(() => (
<ApprovalSection
findingId="finding-1"
investigationOutcome="fix_queued"
findingTitle="CPU saturation"
resourceName="node-1"
resourceType="host"
resourceId="host-1"
/>
));
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(() => (
<ApprovalSection
findingId="finding-1"
investigationOutcome="fix_queued"
findingTitle="CPU saturation"
resourceName="node-1"
resourceType="agent"
resourceId="agent-1"
/>
));
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(() => (
<ApprovalSection
findingId="finding-1"
investigationOutcome="fix_queued"
findingTitle="CPU saturation"
resourceName="node-1"
resourceType="agent"
resourceId="agent-1"
/>
));
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(() => <ApprovalSection findingId="finding-2" investigationOutcome="fix_queued" />);
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();
});
});
@@ -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',
@@ -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,
+27 -11
View File
@@ -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;
@@ -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<UnifiedFinding, 'investigationStatus' | 'investigationOutcome'>,
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<UnifiedFinding, 'id' | 'status' | 'investigationOutcome'>,
finding: Pick<UnifiedFinding, 'id' | 'status' | 'investigationOutcome' | 'investigationRecord'>,
approvals: Pick<ApprovalRequest, 'status' | 'toolId' | 'targetId' | 'expiresAt'>[] = [],
): 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<ApprovalRequest, 'status' | 'toolId' | 'targetId' | 'expiresAt'>[] = [],
@@ -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':
+3
View File
@@ -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
+51 -1
View File
@@ -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
}
+1
View File
@@ -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
+20
View File
@@ -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 {
+43 -18
View File
@@ -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
+38
View File
@@ -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
+43
View File
@@ -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()})
+17
View File
@@ -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")
@@ -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
+20
View File
@@ -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)
}
}
+15 -6
View File
@@ -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
+89
View File
@@ -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")
}
}
@@ -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 &copy
}
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 ""
}
}
+24
View File
@@ -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,
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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)
+2
View File
@@ -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),
}
}
+5 -2
View File
@@ -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,
))))
+1 -1
View File
@@ -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
+42
View File
@@ -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,
}
}
+33
View File
@@ -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)
+19
View File
@@ -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
+130 -2
View File
@@ -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 {
+79
View File
@@ -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)
}
}
@@ -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
+17
View File
@@ -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.
+22
View File
@@ -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
+14 -13
View File
@@ -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 {
+52
View File
@@ -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
@@ -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",
],
)