mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Complete Patrol action lifecycle continuity
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user