Add trusted action review workspace

This commit is contained in:
rcourtman
2026-07-12 01:43:00 +01:00
parent 1294695d6a
commit e13abf84fa
51 changed files with 1600 additions and 203 deletions
@@ -533,6 +533,11 @@ component-local required-scope list.
## Extension Points
Assistant control wording must identify the effective scope as Assistant chat
only. Patrol autonomy and global Actions remain separate authority surfaces;
the chat pill may display the effective `control_level` but must not imply that
read-only chat demotes Patrol or infrastructure action policy.
The manual Patrol route is an extension boundary for scoped work. `POST
/api/ai/patrol/run` (`HandleForcePatrol`) accepts an optional scope body
(`resource_ids` and/or `resource_types`, plus optional `alert_identifier`,
@@ -1661,6 +1661,14 @@ payload shape change when the portal presents compact client rows.
## Extension Points
The frontend action wire mirror in `frontend-modern/src/types/actionAudit.ts`
tracks the durable inbox/detail, policy provenance, dispatch, and
`ActionResultV2` objects. `frontend-modern/src/api/resourceActions.ts` owns
`GET /api/actions?view=pending|settled` and `GET /api/actions/{id}` consumption;
`frontend-modern/src/api/patrol.ts` owns Autopilot acknowledgement create,
activation, effective-mode, and revocation consumption. These mirrors must use
backend JSON names and closed enums rather than frontend authority dialects.
Patrol Open work copy in the browser may clarify row-level evidence, approval,
automatic-action, and verification-result review as presentation over existing
Patrol finding/run/status payloads. Such copy changes do not by themselves add
@@ -548,6 +548,12 @@ or other self-hosted uncapped continuity plans.
## Extension Points
The authenticated app shell exposes `/actions` as a global utility route on
desktop and responsive navigation. `App.tsx` and `AppLayout.tsx` only provide
route/navigation ownership; action authority and result truth remain in the
unified-resources and api-contracts projections. Navigation proof is owned by
the App/AppLayout, routing, and desktop Actions journey tests.
1. Add or change limits through `pkg/licensing/`
2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go`
Hosted entitlement refresh is scoped to active workspace rows only. A tenant
@@ -548,7 +548,7 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
settings shell derives posture summaries or hardening actions. The first-run
shell must use generic host, Docker, and LXC bootstrap commands rather than
probing public status for deployment identity.
9. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
9. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
10. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary.
Assistant session pickers and reloads must restore only safe
`handoff_summary` presentation state from the session list. Loading a plain
@@ -630,6 +630,11 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
## Extension Points
Global Actions review uses the canonical shared `Dialog`, `Button`, and mobile
navigation primitives. The responsive route must preserve named tabs, keyboard
focus, dialog focus containment, and phone-width overflow checks; journey 83
is the desktop/browser accessibility proof and is not mobile-device proof.
Assistant shell entry changes must keep Assistant contextual rather than
generic: `AppLayout.tsx` and the command palette may expose a compact launcher,
but that launcher must attach current-view context before opening the drawer
@@ -59,6 +59,16 @@ Patrol-specific presentation helpers.
## Extension Points
Desktop Autopilot activation consumes the server-owned acknowledgement
contract through `frontend-modern/src/api/patrol.ts` and
`PatrolAutopilotAcknowledgementDialog.tsx`. The UI displays requested versus
effective mode, records the current acknowledgement version before full-mode
activation, exposes revocation, and treats expiry, revocation, version drift,
and activation races as server-authored demotion. Proof is
`PatrolAutopilotAcknowledgementDialog.test.tsx`,
`usePatrolIntelligenceState.test.ts`, and desktop journey 82. This statement
does not certify mobile or physical-device behavior.
Open work descriptions are Patrol-owned operator guidance. They may mention the
visible next step, approvals, and verification results when those words help the
operator understand what to do next, but they must not become a separate proof
@@ -146,7 +146,7 @@ admission records a stable refusal without invoking executor or network code.
9. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts` shared with `unified-resources`: unified resource table state derivation, sort-cycle policy, service sorting, and responsive column layout are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary.
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
16. `frontend-modern/src/useAppRuntimeState.ts` shared with `cloud-paid`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary.
Security-status SSO display labels are part of the existing authenticated
bootstrap payload. The app shell may project `ssoSessionDisplayName` into
@@ -428,10 +428,11 @@
},
{
"path": "frontend-modern/src/routing/routePreload.ts",
"rationale": "the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary",
"rationale": "the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace",
"subsystems": [
"frontend-primitives",
"performance-and-scalability"
"performance-and-scalability",
"unified-resources"
]
},
{
@@ -6056,6 +6057,7 @@
"lane": "L13",
"contract": "docs/release-control/v6/internal/subsystems/unified-resources.md",
"owned_prefixes": [
"frontend-modern/src/features/actions/",
"internal/unifiedresources/"
],
"owned_files": [
@@ -6154,7 +6156,10 @@
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Actions.tsx",
"frontend-modern/src/routing/navigation.ts",
"frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/actionAuditPresentation.ts",
"frontend-modern/src/utils/agentResources.ts",
@@ -6399,6 +6404,30 @@
"internal/unifiedresources/code_standards_test.go"
]
},
{
"id": "product-trust-actions-frontend",
"label": "durable actions inbox and canonical decision review proof",
"match_prefixes": [
"frontend-modern/src/features/actions/"
],
"match_files": [
"frontend-modern/src/pages/Actions.tsx",
"frontend-modern/src/routing/navigation.ts",
"frontend-modern/src/routing/routePreload.ts"
],
"allow_same_subsystem_tests": true,
"test_prefixes": [
"frontend-modern/src/features/actions/__tests__/"
],
"exact_files": [
"frontend-modern/src/api/__tests__/resourceActions.test.ts",
"frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx",
"frontend-modern/src/routing/__tests__/navigation.test.ts",
"frontend-modern/src/routing/__tests__/routePreload.test.ts",
"tests/integration/tests/81-actions-inbox.spec.ts",
"tests/integration/tests/83-product-trust-accessibility.spec.ts"
]
},
{
"id": "resource-action-audit-presentation-helper",
"label": "resource action audit presentation helper proof",
@@ -72,6 +72,16 @@ approval requirement, and digest; absent historical fields read as explicit
`legacy_unknown`. Recovery must never synthesize policy authorities or treat
the descriptive snapshot as reusable dispatch authorization.
The authenticated `/actions` workspace recovers from the durable server list
and detail contracts (`/api/actions` and `/api/actions/{id}`), not from client
memory. Reconnect and resume must rehydrate both pending and settled action
records, then load the selected action by id so expiry, revocation, policy
drift, execution, verification, and compensation truth come from the current
audit record. Resource links may hand an action id into that workspace, but the
handoff is only a lookup key: a pending-only cache, an open dialog snapshot, or
any other client-local state is never recovery authority and must not recreate
an action that the durable store no longer returns.
Assistant access to recovery points and storage evidence is read-side context.
`ai:chat`, relay-mobile chat, and the `read_only` control level cannot project
or execute storage/infrastructure mutation, finding lifecycle changes, or
@@ -158,6 +158,12 @@ temperature, capacity, health, and identity enrichment.
113. `frontend-modern/src/features/docker/DockerAlertsTable.tsx`
114. `frontend-modern/src/features/docker/DockerServicesTable.tsx`
115. `frontend-modern/src/features/docker/DockerStorageUsageTable.tsx`
116. `frontend-modern/src/features/actions/ActionDecisionPacket.tsx`
117. `frontend-modern/src/features/actions/ActionReviewDialog.tsx`
118. `frontend-modern/src/features/actions/actionPresentation.ts`
119. `frontend-modern/src/pages/Actions.tsx`
120. `frontend-modern/src/routing/navigation.ts`
121. `frontend-modern/src/routing/routePreload.ts`
116. `frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx`
117. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
118. `frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx`
@@ -552,11 +558,12 @@ container inventory table.
11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
12. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `storage-recovery`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary.
13. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` shared with `storage-recovery`: Proxmox recoverable workload table rows are both a storage/recovery coverage surface and a unified-resource platform-table consumer boundary.
14. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `frontend-primitives`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
14. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`, `performance-and-scalability`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
15. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `frontend-primitives`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
It must carry the manifest `surface_kind` distinction so `docker` remains
machine-readable as a `runtime-lens` while owning infrastructure sources
remain `platform` entries.
15. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `frontend-primitives`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
16. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `frontend-primitives`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
That shared vocabulary boundary owns the generic `docker` platform label:
selectors, badges, and filter options render it as "Docker / Podman" so
v5 Docker users can still find the runtime surface while Podman-backed
@@ -578,7 +585,7 @@ container inventory table.
display/source family; `platformScopes` is the overlap set used when a
runtime workload belongs to both Docker and an owning infrastructure
platform.
16. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
17. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
`/api/resources` type filters must accept URL-encoded comma-separated lists
from browser query builders exactly like literal comma separators, so Docker
/ Podman runtime pages do not lose `docker-host` inventory while requesting
@@ -600,6 +607,16 @@ container inventory table.
activity must not infer related resources in the frontend.
## Extension Points
The desktop Product Trust projection is owned at
`frontend-modern/src/features/actions/` with the route shell in
`frontend-modern/src/pages/Actions.tsx`. It must consume durable
`ActionAuditRecord.plan.policyDecision` and `result.actionResultV2` without
deriving policy authority or collapsing execution, verification, and recovery
into one outcome. Docker lifecycle controls may create a canonical plan and
open this shared review, but may not auto-approve, auto-execute, or use a
second-click local confirmation. Proof is owned by the colocated action tests,
`DockerNativeTables.test.tsx`, and desktop journeys 81 and 83.
1. Add new resource types and identity fields in `internal/unifiedresources/types.go`
Agentless availability endpoints enter the model as
`ResourceTypeNetworkEndpoint` with `SourceAvailability` and
+2
View File
@@ -87,6 +87,7 @@ function isWorkspaceEntryRoutePath(pathname: string): boolean {
const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
);
const ActionsPage = lazy(() => import('./pages/Actions'));
const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const ProxmoxPage = lazy(() => import('./pages/Proxmox'));
const DockerPage = lazy(() => import('./pages/Docker'));
@@ -573,6 +574,7 @@ function App() {
<Route path={`${STANDALONE_PATH}/*`} component={StandalonePage} />
<Route path="/alerts/*" component={AlertsPage} />
<Route path="/actions/*" component={ActionsPage} />
<Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} />
<Route path="/settings/*" component={SettingsRoute} />
<Route path="*all" component={NotFoundPage} />
+12
View File
@@ -3,6 +3,7 @@ import { Portal } from 'solid-js/web';
import type { JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import BellIcon from 'lucide-solid/icons/bell';
import ListChecksIcon from 'lucide-solid/icons/list-checks';
import SettingsIcon from 'lucide-solid/icons/settings';
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
@@ -266,6 +267,7 @@ export function AppLayout(props: AppLayoutProps) {
vmware: 'vSphere',
standalone: 'Machines',
alerts: 'Alerts',
actions: 'Actions',
ai: 'Patrol',
settings: 'Settings',
};
@@ -512,6 +514,16 @@ export function AppLayout(props: AppLayoutProps) {
breakdown,
icon: BellIcon,
},
{
id: 'actions',
label: 'Actions',
route: '/actions',
tooltip: 'Review proposed changes and verified outcomes',
badge: null,
count: undefined,
breakdown: undefined,
icon: ListChecksIcon,
},
{
id: 'ai',
label: 'Patrol',
@@ -140,7 +140,7 @@ describe('AppLayout navigation icons', () => {
expect(systemGroup).toBeTruthy();
const desktopTabs = within(systemGroup as HTMLElement).getAllByRole('tab');
expect(desktopTabs).toHaveLength(3);
expect(desktopTabs).toHaveLength(4);
desktopTabs.forEach((tab) => {
expect(tab.querySelector('svg')).toBeTruthy();
});
@@ -157,7 +157,7 @@ describe('AppLayout navigation icons', () => {
expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Patrol P' })).toBeNull();
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
['alerts', 'ai', 'settings'].forEach((tabId) => {
['alerts', 'actions', 'ai', 'settings'].forEach((tabId) => {
const button = mobileTablist.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
expect(button).toBeTruthy();
expect(button?.querySelector('svg')).toBeTruthy();
@@ -54,6 +54,107 @@ describe('ActionAuditAPI', () => {
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/actions/pending');
});
it('hydrates durable inbox views and detail with policy provenance and independent result axes', async () => {
const scope = {
orgId: 'org-1',
resourceId: 'docker:container:web',
capabilityName: 'restart',
};
const approvalRequirement = {
version: 1,
floor: 'admin' as const,
quorum: 1,
disallowRequester: false,
};
const policyDecision = {
version: 1,
status: 'resolved' as const,
decisionId: 'policy-decision:sha256:one',
actionId: 'action/one',
scope,
authorities: [
{
kind: 'resource_operator_policy' as const,
sourceId: 'resource-policy:docker:container:web',
revision: 'resource-policy:sha256:one',
status: 'consulted' as const,
scope,
approvalFloor: 'admin' as const,
reasonCodes: ['resource_capability_allowed' as const, 'resource_window_open' as const],
},
],
approvalRequirement,
planningAllowed: true,
requiresApproval: true,
};
const audit: ActionAuditRecord = {
id: 'action/one',
createdAt: '2026-07-12T00:00:00Z',
updatedAt: '2026-07-12T00:01:00Z',
state: 'completed',
request: {
requestId: 'request-1',
resourceId: scope.resourceId,
capabilityName: scope.capabilityName,
reason: 'Recover the edge proxy',
requestedBy: 'pulse_patrol',
},
plan: {
actionId: 'action/one',
requestId: 'request-1',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
approvalRequirement,
rollbackAvailable: true,
expiresAt: '2026-07-12T00:10:00Z',
policyDecision,
},
result: {
success: true,
actionResultV2: {
version: 2,
execution: { status: 'succeeded', summary: 'Dispatch completed.' },
verification: {
status: 'contradicted',
evidenceClass: 'independent',
summary: 'The observer still sees the prior state.',
},
compensation: {
support: 'declared',
status: 'not_attempted',
strategy: 'Restore the previous container state.',
},
},
},
};
apiFetchJSONMock
.mockResolvedValueOnce({ view: 'pending', actions: [], count: 0 })
.mockResolvedValueOnce({ view: 'settled', actions: [audit], count: 1 })
.mockResolvedValueOnce({ audit, events: [] });
await expect(ResourceActionsAPI.listActions('pending', 25)).resolves.toMatchObject({
view: 'pending',
count: 0,
});
await expect(ResourceActionsAPI.listActions('settled', 25)).resolves.toMatchObject({
view: 'settled',
count: 1,
});
const detail = await ResourceActionsAPI.getAction('action/one');
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(1, '/api/actions?view=pending&limit=25');
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(2, '/api/actions?view=settled&limit=25');
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(3, '/api/actions/action%2Fone');
expect(detail.audit.plan.policyDecision).toEqual(policyDecision);
expect(detail.audit.result?.actionResultV2).toMatchObject({
execution: { status: 'succeeded' },
verification: { status: 'contradicted', evidenceClass: 'independent' },
compensation: { support: 'declared', status: 'not_attempted' },
});
});
it('builds the canonical resource-scoped action audit query', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
audits: [
@@ -14,6 +14,9 @@ import {
createSuppressionRuleFromFinding,
resolveFinding,
triggerPatrolRun,
createPatrolAutopilotAcknowledgement,
revokePatrolAutopilotAcknowledgement,
updatePatrolAutonomySettings,
type Finding as PatrolFinding,
} from '@/api/patrol';
import { apiFetchJSON } from '@/utils/apiClient';
@@ -26,6 +29,36 @@ describe('patrol api', () => {
apiFetchJSONMock.mockResolvedValue([] as any);
});
it('uses server acknowledgement and activation endpoints for Autopilot', async () => {
await createPatrolAutopilotAcknowledgement('ack/one');
expect(apiFetchJSONMock).toHaveBeenLastCalledWith(
'/api/ai/patrol/autonomy/acknowledgements',
{ method: 'POST', body: JSON.stringify({ acknowledgement_id: 'ack/one' }) },
);
await updatePatrolAutonomySettings({
autonomy_level: 'full',
acknowledgement_id: 'ack/one',
investigation_budget: 15,
investigation_timeout_sec: 300,
});
expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/autonomy', {
method: 'PUT',
body: JSON.stringify({
autonomy_level: 'full',
acknowledgement_id: 'ack/one',
investigation_budget: 15,
investigation_timeout_sec: 300,
}),
});
await revokePatrolAutopilotAcknowledgement('ack/one', 'operator revoked');
expect(apiFetchJSONMock).toHaveBeenLastCalledWith(
'/api/ai/patrol/autonomy/acknowledgements/ack%2Fone',
{ method: 'DELETE', body: JSON.stringify({ reason: 'operator revoked' }) },
);
});
it('normalizes invalid limits for patrol history queries', async () => {
await getPatrolRunHistory(Number.POSITIVE_INFINITY);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/patrol/runs?limit=30');
@@ -0,0 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn() }));
import { ResourceActionsAPI } from '@/api/resourceActions';
import { apiFetchJSON } from '@/utils/apiClient';
describe('ResourceActionsAPI durable inbox', () => {
const fetchJSON = vi.mocked(apiFetchJSON);
beforeEach(() => fetchJSON.mockReset());
it('lists canonical pending and settled action views without a local projection', async () => {
fetchJSON.mockResolvedValue({ view: 'pending', actions: [], count: 0 });
await ResourceActionsAPI.listActions('pending', 75);
expect(fetchJSON).toHaveBeenCalledWith('/api/actions?view=pending&limit=75');
});
it('loads durable action detail by encoded server action id', async () => {
fetchJSON.mockResolvedValue({ audit: {}, events: [] });
await ResourceActionsAPI.getAction('action/one');
expect(fetchJSON).toHaveBeenCalledWith('/api/actions/action%2Fone');
});
});
+74 -2
View File
@@ -99,13 +99,66 @@ export type InvestigationOutcome =
| 'fix_verification_unknown';
export type PatrolAutonomyLevel = 'monitor' | 'approval' | 'assisted' | 'full';
export type PatrolAutopilotStatusCode =
| 'active'
| 'not_requested'
| 'acknowledgement_required'
| 'acknowledgement_stale_version'
| 'acknowledgement_wrong_org'
| 'acknowledgement_wrong_actor'
| 'acknowledgement_user_required'
| 'acknowledgement_digest_invalid'
| 'acknowledgement_expired'
| 'acknowledgement_revoked'
| 'acknowledgement_conflict'
| 'activation_digest_invalid'
| 'legacy_unlock_ignored'
| 'acknowledgement_store_unavailable'
| 'acknowledgement_activation_raced'
| 'license_required';
export interface PatrolAutopilotAcceptedLimits {
policyAllowlistRequired: boolean;
emergencyStopHonored: boolean;
approvalFloorsHonored: boolean;
verificationReconciledWhenSupported: boolean;
evidenceClassDisclosed: boolean;
inconclusiveOutcomeAllowed: boolean;
executionSuccessIsNotOutcomeTruth: boolean;
activationRevocationBound?: boolean;
}
export interface PatrolAutopilotStatus {
code: PatrolAutopilotStatusCode;
active: boolean;
currentVersion: number;
acknowledgementVersion?: number;
acknowledgementId?: string;
acknowledgementDigest?: string;
acknowledgedBy?: string;
acceptedAt?: string;
expiresAt?: string;
acceptedScope: string[];
acceptedLimits: PatrolAutopilotAcceptedLimits;
}
export interface PatrolAutonomySettings {
autonomy_level: PatrolAutonomyLevel;
full_mode_unlocked: boolean; // User has acknowledged Full mode risks
requested_autonomy_level: PatrolAutonomyLevel;
effective_autonomy_level: PatrolAutonomyLevel;
full_mode_unlocked: boolean;
autopilot_acknowledgement: PatrolAutopilotStatus;
investigation_budget: number; // Max turns per investigation (5-30)
investigation_timeout_sec: number; // Max seconds per investigation (60-600)
}
export interface PatrolAutonomyUpdate {
autonomy_level: PatrolAutonomyLevel;
acknowledgement_id?: string;
investigation_budget: number;
investigation_timeout_sec: number;
}
export interface Investigation {
id: string;
finding_id: string;
@@ -431,7 +484,7 @@ export async function getPatrolAutonomySettings(): Promise<PatrolAutonomySetting
* Update Patrol mode settings
*/
export async function updatePatrolAutonomySettings(
settings: PatrolAutonomySettings,
settings: PatrolAutonomyUpdate,
): Promise<{ success: boolean; settings: PatrolAutonomySettings }> {
return apiFetchJSON('/api/ai/patrol/autonomy', {
method: 'PUT',
@@ -439,6 +492,25 @@ export async function updatePatrolAutonomySettings(
});
}
export async function createPatrolAutopilotAcknowledgement(
acknowledgementId: string,
): Promise<{ created: boolean; acknowledgement: PatrolAutopilotStatus }> {
return apiFetchJSON('/api/ai/patrol/autonomy/acknowledgements', {
method: 'POST',
body: JSON.stringify({ acknowledgement_id: acknowledgementId }),
});
}
export async function revokePatrolAutopilotAcknowledgement(
acknowledgementId: string,
reason = 'Operator revoked Autopilot from Patrol controls.',
): Promise<{ revoked: boolean; created: boolean; acknowledgement_id: string }> {
return apiFetchJSON(
`/api/ai/patrol/autonomy/acknowledgements/${encodeURIComponent(acknowledgementId)}`,
{ method: 'DELETE', body: JSON.stringify({ reason }) },
);
}
/**
* Get investigation details for a finding
*/
@@ -1,8 +1,11 @@
import { apiFetchJSON } from '@/utils/apiClient';
import type {
ActionAuditPlan,
ActionDetailResponse,
ActionDecisionResponse,
ActionExecutionResponse,
ActionInboxResponse,
ActionInboxView,
PendingActionsResponse,
ResourceActionRequest,
} from '@/types/actionAudit';
@@ -10,6 +13,15 @@ import type {
export type ActionDecisionOutcome = 'approved' | 'rejected';
export class ResourceActionsAPI {
static async listActions(view: ActionInboxView, limit = 100): Promise<ActionInboxResponse> {
const params = new URLSearchParams({ view, limit: String(limit) });
return apiFetchJSON<ActionInboxResponse>(`/api/actions?${params.toString()}`);
}
static async getAction(actionId: string): Promise<ActionDetailResponse> {
return apiFetchJSON<ActionDetailResponse>(`/api/actions/${encodeURIComponent(actionId)}`);
}
static async listPendingActions(): Promise<PendingActionsResponse> {
return apiFetchJSON<PendingActionsResponse>('/api/actions/pending');
}
@@ -3396,7 +3396,7 @@ describe('AIChat', () => {
describe('control level', () => {
it('displays Read-only as default control label', () => {
renderChat();
expect(screen.getByText('Read-only')).toBeInTheDocument();
expect(screen.getByText('Chat: Read-only')).toBeInTheDocument();
});
it('opens control menu on click and focuses the current mode', async () => {
@@ -5581,7 +5581,7 @@ describe('AIChat', () => {
renderChat();
await waitFor(() => {
expect(screen.getByText('Ask first')).toBeInTheDocument();
expect(screen.getByText('Chat: Ask first')).toBeInTheDocument();
});
expect(
screen.queryByText(/Approval required for this dashboard brief/),
@@ -5622,7 +5622,7 @@ describe('AIChat', () => {
renderChat();
await waitFor(() => {
expect(screen.getByText('Ask first')).toBeInTheDocument();
expect(screen.getByText('Chat: Ask first')).toBeInTheDocument();
});
expect(screen.getByText('Approval required before any action.')).toBeInTheDocument();
expect(
@@ -5655,7 +5655,7 @@ describe('AIChat', () => {
renderChat();
await waitFor(() => {
expect(screen.getByText('Ask first')).toBeInTheDocument();
expect(screen.getByText('Chat: Ask first')).toBeInTheDocument();
});
expect(screen.getByText('Approval required before any action.')).toBeInTheDocument();
expect(
@@ -5301,7 +5301,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<span
class={`h-1.5 w-1.5 rounded-full ${controlPresentation().dotClassName}`}
/>
<span>{controlPresentation().label}</span>
<span>Chat: {controlPresentation().label}</span>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@@ -134,7 +134,9 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300';
return (
<div class={`rounded border px-2 py-1 text-[10px] ${toneClass}`}>
<div class="font-medium">{v.success ? 'Verified' : 'Verification failed'}</div>
<div class="font-medium">
{v.success ? 'Legacy check passed (source unclassified)' : 'Legacy check failed (source unclassified)'}
</div>
<Show when={v.command}>
<div class="mt-0.5 font-mono text-[10px] opacity-80">{v.command}</div>
</Show>
@@ -12,12 +12,11 @@ describe('ResourceActionHistory verification rendering', () => {
it('renders the post-dispatch verification outcome on each audit row when ran=true', () => {
// The broker's read-after-write verification outcome lives on
// result.verification (ActionVerificationResult). The audit history row
// must surface it so operators can see "Pulse confirmed the workload
// service is active" — not just "command exit 0". Pin the wiring so the
// surface cannot silently regress to an output-only render.
// must surface it without implying independent evidence. Pin the wiring
// so the surface cannot silently regress to an output-only render.
expect(sourceText).toContain('shouldRenderActionAuditVerification(props.audit)');
expect(sourceText).toContain('Verified');
expect(sourceText).toContain('Verification failed');
expect(sourceText).toContain('Legacy check passed (source unclassified)');
expect(sourceText).toContain('Legacy check failed (source unclassified)');
});
it('shows the verification command and output verbatim when present', () => {
@@ -71,7 +70,7 @@ describe('ResourceActionHistory verification rendering', () => {
);
const actionHistory = within(screen.getByTestId('resource-action-history-section'));
expect(actionHistory.getAllByText('Verified')).toHaveLength(1);
expect(actionHistory.getAllByText('Legacy check passed (source unclassified)')).toHaveLength(1);
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
expect(actionHistory.queryByText('should not render')).toBeNull();
expect(actionHistory.queryByText('sensitive output')).toBeNull();
@@ -75,6 +75,8 @@ function statePresentation(state: ActionAuditState): {
return { label: 'Approved', tone: 'success' };
case 'rejected':
return { label: 'Rejected', tone: 'warning' };
case 'expired':
return { label: 'Expired', tone: 'warning' };
case 'executing':
return { label: 'Applying', tone: 'info' };
case 'completed':
@@ -104,7 +104,7 @@ function audit(
investigationId: 'investigation-1',
proposalId: 'proposal-1',
},
verificationOutcome: { status: verification },
verificationOutcome: { status: verification as never },
};
}
@@ -17,6 +17,7 @@ const StandaloneIcon: Component<{ class?: string }> = (props) => (
);
const ProxmoxIcon: Component<{ class?: string }> = (props) => <span class={props.class}>PX</span>;
const AlertsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AL</span>;
const ActionsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AC</span>;
const SettingsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>SE</span>;
const PatrolIcon: Component<{ class?: string }> = (props) => (
<svg aria-label="Pulse Patrol" class={props.class} viewBox="0 0 24 24">
@@ -117,6 +118,45 @@ describe('MobileNavBar', () => {
expect(within(navList).queryByText('Needs Attention')).toBeNull();
});
it('keeps Actions named, reachable, and route-correct among monitor-first destinations', () => {
const onUtilityClick = vi.fn();
const utilityTabs = [
{ id: 'settings' as const, label: 'Settings', route: '/settings', tooltip: 'Settings', badge: null, count: undefined, breakdown: undefined, icon: SettingsIcon },
{ id: 'ai' as const, label: 'Patrol', route: '/patrol', tooltip: 'Patrol', badge: null, count: undefined, breakdown: undefined, icon: PatrolIcon },
{ id: 'actions' as const, label: 'Actions', route: '/actions', tooltip: 'Review actions', badge: null, count: undefined, breakdown: undefined, icon: ActionsIcon },
{ id: 'alerts' as const, label: 'Alerts', route: '/alerts', tooltip: 'Alerts', badge: null, count: undefined, breakdown: undefined, icon: AlertsIcon },
];
const { container } = render(() => (
<MobileNavBar
activeTab={() => 'actions'}
primaryTabs={() => []}
utilityTabs={() => utilityTabs}
onPrimaryClick={() => {}}
onUtilityClick={onUtilityClick}
/>
));
const navList = screen.getByRole('tablist', { name: 'Mobile navigation' });
const actionsButton = within(navList).getByRole('button', { name: 'Actions' });
expect(Array.from(container.querySelectorAll('button[data-tab-id]')).map((button) => button.getAttribute('data-tab-id'))).toEqual([
'alerts',
'actions',
'ai',
'settings',
]);
expect(actionsButton).toHaveAttribute('data-tab-id', 'actions');
expect(actionsButton).toHaveAttribute('type', 'button');
actionsButton.focus();
expect(actionsButton).toHaveFocus();
fireEvent.click(actionsButton);
expect(onUtilityClick).toHaveBeenCalledWith(expect.objectContaining({
id: 'actions',
label: 'Actions',
route: '/actions',
}));
});
it('allows inactive platform tabs to render without an active mobile tab', () => {
const { container } = render(() => (
<MobileNavBar
@@ -16,7 +16,7 @@ export type MobileNavBarPrimaryTab = {
};
export type MobileNavBarUtilityTab = {
id: 'alerts' | 'ai' | 'settings';
id: 'alerts' | 'actions' | 'ai' | 'settings';
label: string;
route: string;
tooltip: string;
@@ -44,7 +44,7 @@ const MOBILE_NAV_PRIMARY_PRIORITY = [
'standalone',
] as const;
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'ai', 'settings'] as const;
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'actions', 'ai', 'settings'] as const;
export function buildOrderedMobileNavTabs<T extends { id: string }>(
tabs: T[],
@@ -0,0 +1,91 @@
import { For, Show, createMemo, type Component } from 'solid-js';
import type { ActionAuditRecord } from '@/types/actionAudit';
import {
formatActionName,
formatEvidenceClass,
formatPolicyAuthority,
formatPolicyReason,
verificationTruthLabel,
} from './actionPresentation';
export const ActionDecisionPacket: Component<{ audit: ActionAuditRecord }> = (props) => {
const policy = () => props.audit.plan.policyDecision;
const result = () => props.audit.result?.actionResultV2;
const expiry = createMemo(() => {
const value = new Date(props.audit.plan.expiresAt ?? '');
return Number.isNaN(value.valueOf()) ? 'Not recorded' : value.toLocaleString();
});
return (
<div class="space-y-4" data-testid="action-decision-packet">
<section aria-labelledby="action-intent-heading" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-intent-heading" class="text-sm font-semibold text-base-content">What will happen</h3>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-muted">Action</dt><dd class="font-medium">{formatActionName(props.audit.request.capabilityName)}</dd></div>
<div><dt class="text-muted">Resource</dt><dd class="break-all font-medium">{props.audit.request.resourceId}</dd></div>
<div class="sm:col-span-2"><dt class="text-muted">Reason</dt><dd>{props.audit.request.reason}</dd></div>
<Show when={props.audit.plan.preflight?.currentState}><div><dt class="text-muted">Current state</dt><dd>{props.audit.plan.preflight?.currentState}</dd></div></Show>
<Show when={props.audit.plan.preflight?.intendedChange}><div><dt class="text-muted">Intended change</dt><dd>{props.audit.plan.preflight?.intendedChange}</dd></div></Show>
<div><dt class="text-muted">Approval expires</dt><dd>{expiry()}</dd></div>
<div><dt class="text-muted">Rollback declared</dt><dd>{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}</dd></div>
</dl>
<Show when={(props.audit.plan.predictedBlastRadius ?? []).length > 0}>
<div class="mt-3"><div class="text-sm text-muted">Also affected</div><ul class="mt-1 list-disc pl-5 text-sm"><For each={props.audit.plan.predictedBlastRadius}>{(resource) => <li>{resource}</li>}</For></ul></div>
</Show>
</section>
<section aria-labelledby="action-policy-heading" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-policy-heading" class="text-sm font-semibold text-base-content">Why Pulse allows this review</h3>
<Show when={policy()?.status === 'resolved'} fallback={<p class="mt-2 text-sm text-amber-700">This older action has no server-recorded policy provenance. Re-plan it before acting.</p>}>
<p class="mt-1 text-xs text-muted">Server decision {policy()?.decisionId}</p>
<div class="mt-3 space-y-2">
<For each={policy()?.authorities ?? []}>
{(authority) => (
<div class="rounded border border-border-subtle bg-surface-hover p-3">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<span class="font-medium">{formatPolicyAuthority(authority)}</span>
<span class="text-muted">{authority.status === 'consulted' ? 'Consulted' : formatActionName(authority.status)}</span>
</div>
<div class="mt-1 text-xs text-muted">{authority.sourceId}<Show when={authority.revision}> · {authority.revision}</Show></div>
<ul class="mt-2 list-disc pl-5 text-xs text-base-content"><For each={authority.reasonCodes}>{(reason) => <li>{formatPolicyReason(reason)}</li>}</For></ul>
</div>
)}
</For>
</div>
<p class="mt-3 text-xs text-muted">This records planning-time policy evidence. Pulse checks current authority again before execution.</p>
</Show>
</section>
<Show when={result()}>
{(truth) => (
<section aria-labelledby="action-result-heading" class="space-y-3">
<h3 id="action-result-heading" class="text-sm font-semibold text-base-content">Recorded outcome</h3>
<div data-testid="action-execution-truth" class="rounded-lg border border-border bg-surface p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Execution</div>
<div class="mt-1 font-semibold">{truth().execution.status === 'not_run' ? 'Did not run' : formatActionName(truth().execution.status)}</div>
<Show when={truth().execution.reasonCode}><div class="mt-1 text-xs text-muted">Reason: {formatActionName(truth().execution.reasonCode!)}</div></Show>
<Show when={truth().execution.summary}><p class="mt-2 text-sm">{truth().execution.summary}</p></Show>
</div>
<div data-testid="action-verification-truth" class="rounded-lg border border-border bg-surface p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Verification</div>
<div class="mt-1 font-semibold">{verificationTruthLabel(truth().verification.status, truth().verification.evidenceClass)}</div>
<div class="mt-1 text-sm text-muted">Source: {formatEvidenceClass(truth().verification.evidenceClass)}</div>
<Show when={truth().verification.reasonCode}><div class="mt-1 text-xs text-muted">Reason: {formatActionName(truth().verification.reasonCode!)}</div></Show>
<Show when={truth().verification.summary}><p class="mt-2 text-sm">{truth().verification.summary}</p></Show>
<Show when={(truth().verification.evidence ?? []).length > 0}>
<details class="mt-3 rounded border border-border-subtle p-3 text-xs"><summary class="cursor-pointer font-medium">Evidence details</summary><ul class="mt-2 space-y-2"><For each={truth().verification.evidence}>{(evidence) => <li><div>{evidence.summary || evidence.method}</div><div class="text-muted">Observed by {evidence.observerId} · {evidence.observerTrustDomain}</div></li>}</For></ul></details>
</Show>
</div>
<div data-testid="action-compensation-truth" class="rounded-lg border border-border bg-surface p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Recovery</div>
<div class="mt-1 font-semibold">{formatActionName(truth().compensation.status)}</div>
<div class="mt-1 text-sm text-muted">Support: {formatActionName(truth().compensation.support)}</div>
<Show when={truth().compensation.strategy}><div class="mt-1 text-sm">Strategy: {truth().compensation.strategy}</div></Show>
<Show when={truth().compensation.summary}><p class="mt-2 text-sm">{truth().compensation.summary}</p></Show>
</div>
</section>
)}
</Show>
</div>
);
};
@@ -0,0 +1,104 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { notificationStore } from '@/stores/notifications';
import type { ActionDetailResponse } from '@/types/actionAudit';
import { ActionDecisionPacket } from './ActionDecisionPacket';
import { formatActionName } from './actionPresentation';
export const ActionReviewDialog: Component<{
detail: ActionDetailResponse | null;
onClose: () => void;
onChanged?: (detail: ActionDetailResponse) => void | Promise<void>;
}> = (props) => {
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal('');
const [clock, setClock] = createSignal(Date.now());
const audit = () => props.detail?.audit;
createEffect(() => {
if (!props.detail) return;
setClock(Date.now());
const timer = window.setInterval(() => setClock(Date.now()), 1000);
onCleanup(() => window.clearInterval(timer));
});
const hasCurrentPolicyProvenance = createMemo(
() => audit()?.plan.policyDecision?.status === 'resolved',
);
const isExpired = createMemo(() => {
const expiresAt = audit()?.plan.expiresAt;
if (!expiresAt) return true;
const timestamp = new Date(expiresAt).valueOf();
return Number.isNaN(timestamp) || timestamp <= clock();
});
const canDecide = () => hasCurrentPolicyProvenance() && !isExpired() && audit()?.state === 'pending_approval';
const canExecute = () =>
hasCurrentPolicyProvenance() &&
!isExpired() &&
(audit()?.state === 'approved' ||
(audit()?.state === 'planned' && !audit()?.plan.requiresApproval));
const invalidActionMessage = createMemo(() => {
if (!hasCurrentPolicyProvenance()) return 'This action has no current server policy provenance. Close it and create a new plan before approving or running anything.';
if (isExpired()) return 'This action review expired. Close it and create a new plan so current resource and policy state can be checked again.';
return '';
});
const refresh = async () => {
const actionId = audit()?.id;
if (!actionId) return;
const detail = await ResourceActionsAPI.getAction(actionId);
await props.onChanged?.(detail);
};
const decide = async (outcome: 'approved' | 'rejected') => {
const action = audit();
if (!action || busy()) return;
setBusy(true); setError('');
try {
await ResourceActionsAPI.decideAction(action.id, outcome, `Operator ${outcome} from Actions review.`);
await refresh();
notificationStore.success(outcome === 'approved' ? 'Action approved. Review once more before running it.' : 'Action rejected.');
if (outcome === 'rejected') props.onClose();
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'The decision could not be recorded.';
setError(message);
} finally { setBusy(false); }
};
const execute = async () => {
const action = audit();
if (!action || busy()) return;
setBusy(true); setError('');
try {
await ResourceActionsAPI.executeAction(action.id, 'Operator confirmed execution from Actions review.');
await refresh();
notificationStore.success('Action dispatch response recorded. Review execution, verification, and recovery separately.');
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'The action could not be run.';
setError(message);
try { await refresh(); } catch { /* keep the actionable execution error */ }
} finally { setBusy(false); }
};
return (
<Dialog isOpen={Boolean(props.detail)} onClose={props.onClose} ariaLabelledBy="action-review-title" panelClass="max-w-3xl">
<Show when={audit()}>
{(record) => (
<div class="flex max-h-[min(90vh,900px)] flex-col">
<header class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div><p class="text-xs font-semibold uppercase tracking-wide text-muted">Governed action review</p><h2 id="action-review-title" class="mt-1 text-xl font-semibold">{formatActionName(record().request.capabilityName)}</h2><p class="mt-1 break-all text-sm text-muted">{record().request.resourceId}</p></div>
<Button variant="ghost" size="icon" aria-label="Close action review" onClick={props.onClose}><XIcon class="h-5 w-5" /></Button>
</header>
<div class="overflow-y-auto px-5 py-4"><ActionDecisionPacket audit={record()} /><Show when={invalidActionMessage()}><div role="alert" data-testid="action-review-invalid" class="mt-4 rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">{invalidActionMessage()}</div></Show><Show when={error()}><div role="alert" class="mt-4 rounded border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950/40 dark:text-red-200">{error()}</div></Show></div>
<footer class="flex flex-col-reverse gap-2 border-t border-border px-5 py-4 sm:flex-row sm:justify-end">
<Button onClick={props.onClose}>Close</Button>
<Show when={canDecide()}><Button variant="danger" disabled={busy()} onClick={() => void decide('rejected')}>Reject</Button><Button variant="primary" isLoading={busy()} onClick={() => void decide('approved')}>Approve</Button></Show>
<Show when={canExecute()}><Button variant="primary" isLoading={busy()} onClick={() => void execute()}>Run action</Button></Show>
</footer>
</div>
)}
</Show>
</Dialog>
);
};
@@ -0,0 +1,62 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { ActionAuditRecord } from '@/types/actionAudit';
import { ActionDecisionPacket } from '../ActionDecisionPacket';
afterEach(cleanup);
const audit: ActionAuditRecord = {
id: 'action-1', createdAt: '2026-07-12T00:00:00Z', updatedAt: '2026-07-12T00:01:00Z', state: 'completed', decisionRevision: 1,
request: { requestId: 'request-1', resourceId: 'docker:container:edge', capabilityName: 'restart', reason: 'Recover the edge proxy', requestedBy: 'ui:docker-page' },
plan: {
actionId: 'action-1', requestId: 'request-1', allowed: true, requiresApproval: true, approvalPolicy: 'admin', approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, rollbackAvailable: false,
plannedAt: '2026-07-12T00:00:00Z', expiresAt: '2026-07-12T00:10:00Z', resourceVersion: 'resource:sha256:one', policyVersion: 'policy:sha256:one', planHash: 'sha256:plan',
policyDecision: { version: 1, status: 'resolved', decisionId: 'policy-decision:sha256:one', actionId: 'action-1', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: true, requiresApproval: true, authorities: [
{ kind: 'capability_registry', sourceId: 'capability-registry:restart', revision: 'policy:sha256:one', status: 'consulted', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalFloor: 'admin', reasonCodes: ['capability_approval_admin', 'capability_auto_low_risk'] },
{ kind: 'resource_operator_policy', sourceId: 'resource-operator-policy:docker:container:edge', revision: 'resource-policy:sha256:one', status: 'consulted', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalFloor: 'admin', reasonCodes: ['resource_capability_allowed', 'resource_window_open'] },
] },
},
result: { success: true, actionResultV2: { version: 2, execution: { status: 'succeeded', summary: 'Dispatch completed.' }, verification: { status: 'confirmed', evidenceClass: 'independent', summary: 'A separate observer saw the target state.', evidence: [] }, compensation: { support: 'unavailable', status: 'not_available' } } },
verificationOutcome: { status: 'verified' },
};
describe('ActionDecisionPacket', () => {
it('shows server policy provenance, expiry, and independent result evidence as separate truth', () => {
render(() => <ActionDecisionPacket audit={audit} />);
expect(screen.getByText('Why Pulse allows this review')).toBeInTheDocument();
expect(screen.getByText('Capability safety policy')).toBeInTheDocument();
expect(screen.getByText('Policy for this resource')).toBeInTheDocument();
expect(within(screen.getByTestId('action-execution-truth')).getByText('Succeeded')).toBeInTheDocument();
expect(screen.getByText('Confirmed by independent observer')).toBeInTheDocument();
expect(screen.getByText('Source: Independent observer')).toBeInTheDocument();
});
it.each([
['agent-attested confirmed', 'succeeded', 'confirmed', 'agent_attested', 'Succeeded', 'Confirmed by executing agent', 'Source: Executing agent'],
['independent confirmed', 'succeeded', 'confirmed', 'independent', 'Succeeded', 'Confirmed by independent observer', 'Source: Independent observer'],
['succeeded plus contradicted', 'succeeded', 'contradicted', 'independent', 'Succeeded', 'Outcome contradicted', 'Source: Independent observer'],
['failed plus confirmed', 'failed', 'confirmed', 'agent_attested', 'Failed', 'Confirmed by executing agent', 'Source: Executing agent'],
['not run plus not attempted', 'not_run', 'not_attempted', 'none', 'Did not run', 'Outcome not verified', 'Source: No evidence source'],
['inconclusive plus confirmed', 'inconclusive', 'confirmed', 'independent', 'Inconclusive', 'Confirmed by independent observer', 'Source: Independent observer'],
['confirmed without evidence source', 'succeeded', 'confirmed', 'none', 'Succeeded', 'Confirmation lacks an evidence source', 'Source: No evidence source'],
] as const)('keeps execution, verification source, and recovery separate for %s', (_name, execution, verification, evidenceClass, executionLabel, verificationLabel, sourceLabel) => {
const variant: ActionAuditRecord = {
...audit,
result: {
success: execution === 'succeeded',
actionResultV2: {
version: 2,
execution: { status: execution },
verification: { status: verification, evidenceClass },
compensation: { support: 'declared', status: 'not_attempted', strategy: 'restart previous container' },
},
},
};
render(() => <ActionDecisionPacket audit={variant} />);
expect(within(screen.getByTestId('action-execution-truth')).getByText(executionLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-verification-truth')).getByText(verificationLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-verification-truth')).getByText(sourceLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-compensation-truth')).getByText('Not Attempted')).toBeInTheDocument();
cleanup();
});
});
@@ -0,0 +1,41 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
import { ActionReviewDialog } from '../ActionReviewDialog';
vi.mock('@/api/resourceActions', () => ({ ResourceActionsAPI: { getAction: vi.fn(), decideAction: vi.fn(), executeAction: vi.fn() } }));
vi.mock('@/stores/notifications', () => ({ notificationStore: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
afterEach(() => { cleanup(); vi.useRealTimers(); });
const makeAudit = (status: 'resolved' | 'legacy_unknown', expiresAt: string): ActionAuditRecord => ({
id: 'action-1', createdAt: '2026-07-12T00:00:00Z', updatedAt: '2026-07-12T00:00:00Z', state: 'pending_approval', decisionRevision: 0,
request: { requestId: 'request-1', resourceId: 'docker:container:edge', capabilityName: 'restart', reason: 'Recover edge', requestedBy: 'operator' },
plan: {
actionId: 'action-1', requestId: 'request-1', allowed: true, requiresApproval: true, approvalPolicy: 'admin', approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, rollbackAvailable: false, expiresAt,
policyDecision: status === 'resolved'
? { version: 1, status, decisionId: 'decision-1', actionId: 'action-1', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, authorities: [{ kind: 'capability_registry', sourceId: 'capability-registry:restart', status: 'consulted', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalFloor: 'admin', reasonCodes: ['capability_approval_admin'] }], approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: true, requiresApproval: true }
: { version: 0, status, scope: { orgId: '', resourceId: '', capabilityName: '' }, authorities: [], approvalRequirement: { version: 0, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: false, requiresApproval: true },
},
verificationOutcome: { status: 'unknown' },
});
const detail = (audit: ActionAuditRecord): ActionDetailResponse => ({ audit, events: [] });
describe('ActionReviewDialog trust gates', () => {
it('offers no approve or run control for legacy provenance', () => {
render(() => <ActionReviewDialog detail={detail(makeAudit('legacy_unknown', '2099-01-01T00:00:00Z'))} onClose={vi.fn()} />);
expect(screen.getByTestId('action-review-invalid')).toHaveTextContent('no current server policy provenance');
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Run action' })).toBeNull();
});
it('removes decision controls when expiry passes while the dialog remains open', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-12T00:00:00Z'));
render(() => <ActionReviewDialog detail={detail(makeAudit('resolved', '2026-07-12T00:00:00.500Z'))} onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000);
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull();
expect(screen.getByTestId('action-review-invalid')).toHaveTextContent('review expired');
});
});
@@ -0,0 +1,79 @@
import type {
ActionEvidenceClass,
ActionPolicyAuthorityFactor,
ActionPolicyReasonCode,
ActionVerificationTruthStatus,
} from '@/types/actionAudit';
export const formatActionName = (value: string): string =>
value
.replace(/[._-]+/g, ' ')
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase());
export const formatPolicyAuthority = (factor: ActionPolicyAuthorityFactor): string => {
switch (factor.kind) {
case 'capability_registry':
return 'Capability safety policy';
case 'tenant_patrol_policy':
return 'Patrol policy for this organization';
case 'resource_operator_policy':
return 'Policy for this resource';
}
};
const POLICY_REASON_LABELS: Record<ActionPolicyReasonCode, string> = {
capability_approval_none: 'No approval floor',
capability_approval_admin: 'Administrator approval required',
capability_approval_mfa: 'Verified approval required',
capability_dry_run_only: 'Dry run only',
capability_auto_never: 'Automatic execution not allowed',
capability_auto_low_risk: 'Eligible for low-risk automation',
capability_auto_elevated: 'Eligible for elevated automation',
tenant_policy_unavailable: 'Organization policy unavailable',
tenant_emergency_stop: 'Emergency stop is active',
tenant_mode_monitor: 'Patrol is in Watch only',
tenant_mode_assisted: 'Patrol is in Safe fixes',
tenant_mode_full: 'Patrol is in Autopilot',
tenant_mode_unknown: 'Patrol mode is unknown',
tenant_full_mode_locked: 'Autopilot acknowledgement is not active',
tenant_full_mode_unlocked: 'Autopilot acknowledgement is active',
resource_policy_unavailable: 'Resource policy unavailable',
resource_policy_missing: 'No resource policy is recorded',
resource_never_auto_remediate: 'Automatic remediation is blocked for this resource',
resource_capability_allowed: 'This action is allowed for the resource',
resource_capability_not_allowed: 'This action is not allowed for the resource',
resource_window_open: 'Resource action window is open',
resource_window_closed: 'Resource action window is closed',
};
export const formatPolicyReason = (reason: ActionPolicyReasonCode): string =>
POLICY_REASON_LABELS[reason];
export const formatEvidenceClass = (value: ActionEvidenceClass): string => {
switch (value) {
case 'independent':
return 'Independent observer';
case 'agent_attested':
return 'Executing agent';
case 'none':
return 'No evidence source';
}
};
export const verificationTruthLabel = (status: ActionVerificationTruthStatus, evidenceClass?: ActionEvidenceClass): string => {
switch (status) {
case 'confirmed':
return evidenceClass === 'independent'
? 'Confirmed by independent observer'
: evidenceClass === 'agent_attested'
? 'Confirmed by executing agent'
: 'Confirmation lacks an evidence source';
case 'contradicted':
return 'Outcome contradicted';
case 'inconclusive':
return 'Outcome inconclusive';
case 'not_attempted':
return 'Outcome not verified';
}
};
@@ -4,7 +4,9 @@ import PlayIcon from 'lucide-solid/icons/play';
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import SquareIcon from 'lucide-solid/icons/square';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
import { notificationStore } from '@/stores/notifications';
import type { ActionDetailResponse } from '@/types/actionAudit';
import type { Resource } from '@/types/resource';
import {
DOCKER_CONTAINER_LIFECYCLE_ACTIONS,
@@ -15,13 +17,11 @@ import {
} from './dockerContainerLifecycleActions';
export type DockerContainerLifecycleSurface = 'docker-page' | 'resource-detail';
export type DockerContainerLifecycleSettledContext = {
action: DockerContainerLifecycleAction;
actionId: string;
resource: Resource;
};
export type DockerContainerLifecycleControlsProps = {
resource: Resource;
class?: string;
@@ -33,72 +33,42 @@ const buttonBaseClass =
'inline-flex h-10 w-10 shrink-0 items-center justify-center rounded border text-muted transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface sm:h-7 sm:w-7';
const enabledButtonClass =
'border-border-subtle bg-surface hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-950/40 dark:hover:text-blue-300';
const confirmButtonClass =
'border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300';
const disabledButtonClass = 'cursor-not-allowed border-border-subtle bg-surface-alt opacity-55';
const runningButtonClass =
'cursor-wait border-blue-400 bg-blue-50 text-blue-700 dark:bg-blue-950/40';
const successButtonClass =
'border-emerald-400 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40';
const newRequestId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `docker-container-action-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const newRequestId = (): string =>
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `docker-container-action-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const iconForAction = (action: DockerContainerLifecycleAction): Component<{ class?: string }> => {
switch (action) {
case 'start':
return PlayIcon;
case 'stop':
return SquareIcon;
case 'restart':
return RotateCwIcon;
case 'start': return PlayIcon;
case 'stop': return SquareIcon;
case 'restart': return RotateCwIcon;
}
};
const errorMessage = (error: unknown): string =>
error instanceof Error && error.message.trim() ? error.message.trim() : 'Action failed';
const surfaceLabel = (surface: DockerContainerLifecycleSurface | undefined): string =>
const surfaceLabel = (surface?: DockerContainerLifecycleSurface): string =>
surface === 'resource-detail' ? 'resource details' : 'Docker page';
const requestedByForSurface = (surface: DockerContainerLifecycleSurface | undefined): string =>
const requestedByForSurface = (surface?: DockerContainerLifecycleSurface): string =>
surface === 'resource-detail' ? 'ui:resource-detail' : 'ui:docker-page';
export const DockerContainerLifecycleControls: Component<DockerContainerLifecycleControlsProps> = (
props,
) => {
const [confirmingAction, setConfirmingAction] =
createSignal<DockerContainerLifecycleAction | null>(null);
const [runningAction, setRunningAction] = createSignal<DockerContainerLifecycleAction | null>(
null,
);
const [completedAction, setCompletedAction] = createSignal<DockerContainerLifecycleAction | null>(
null,
);
export const DockerContainerLifecycleControls: Component<DockerContainerLifecycleControlsProps> = (props) => {
const [planningAction, setPlanningAction] = createSignal<DockerContainerLifecycleAction | null>(null);
const [reviewAction, setReviewAction] = createSignal<DockerContainerLifecycleAction | null>(null);
const [reviewDetail, setReviewDetail] = createSignal<ActionDetailResponse | null>(null);
const [lastError, setLastError] = createSignal('');
const executeLifecycleAction = async (action: DockerContainerLifecycleAction) => {
const prepareLifecycleReview = async (action: DockerContainerLifecycleAction) => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
if (disabledReason || runningAction()) return;
if (confirmingAction() !== action) {
setConfirmingAction(action);
setLastError('');
return;
}
if (disabledReason || planningAction()) return;
const containerName = dockerContainerLifecycleName(props.resource);
const runtimeLabel = dockerContainerRuntimeLabel(props.resource);
const reason = `${action} ${runtimeLabel} container ${containerName} from the ${surfaceLabel(
props.surface,
)}.`;
setRunningAction(action);
setConfirmingAction(null);
const reason = `${action} ${runtimeLabel} container ${containerName} from the ${surfaceLabel(props.surface)}.`;
setPlanningAction(action);
setLastError('');
try {
const plan = await ResourceActionsAPI.planAction({
requestId: newRequestId(),
@@ -108,37 +78,15 @@ export const DockerContainerLifecycleControls: Component<DockerContainerLifecycl
reason,
requestedBy: requestedByForSurface(props.surface),
});
if (!plan.allowed) {
throw new Error(plan.message || 'Pulse refused the action plan.');
}
if (plan.requiresApproval) {
await ResourceActionsAPI.decideAction(plan.actionId, 'approved', reason);
}
const result = await ResourceActionsAPI.executeAction(plan.actionId, reason);
if (result.result && !result.result.success) {
throw new Error(result.result.errorMessage || 'The action did not complete successfully.');
}
try {
await props.onActionSettled?.({
action,
actionId: plan.actionId,
resource: props.resource,
});
} catch {
notificationStore.warning('Action requested. Refresh container inventory to verify state.');
}
setCompletedAction(action);
window.setTimeout(
() => setCompletedAction((current) => (current === action ? null : current)),
2000,
);
notificationStore.success(`${runtimeLabel} container ${containerName}: ${action} requested`);
if (!plan.allowed) throw new Error(plan.message || 'Pulse refused the action plan.');
setReviewAction(action);
setReviewDetail(await ResourceActionsAPI.getAction(plan.actionId));
} catch (error) {
const message = errorMessage(error);
const message = error instanceof Error && error.message.trim() ? error.message.trim() : 'Action review could not be prepared.';
setLastError(message);
notificationStore.error(message);
} finally {
setRunningAction(null);
setPlanningAction(null);
}
};
@@ -146,61 +94,38 @@ export const DockerContainerLifecycleControls: Component<DockerContainerLifecycl
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
const containerName = dockerContainerLifecycleName(props.resource);
if (disabledReason) return `${label} unavailable: ${disabledReason}`;
if (runningAction() === action) return `${label} ${containerName} through governed action`;
if (confirmingAction() === action) return `Click again to ${action} ${containerName}`;
if (planningAction() === action) return `Preparing review for ${action} ${containerName}`;
if (lastError()) return `${label} ${containerName}; last error: ${lastError()}`;
return `${label} ${containerName} through governed action`;
};
const classForAction = (action: DockerContainerLifecycleAction): string => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
if (runningAction() === action) return `${buttonBaseClass} ${runningButtonClass}`;
if (completedAction() === action) return `${buttonBaseClass} ${successButtonClass}`;
if (disabledReason || runningAction()) return `${buttonBaseClass} ${disabledButtonClass}`;
if (confirmingAction() === action) return `${buttonBaseClass} ${confirmButtonClass}`;
return `${buttonBaseClass} ${enabledButtonClass}`;
return `Review ${action} for ${containerName}`;
};
return (
<div
class={`inline-flex items-center justify-end gap-1 ${props.class ?? ''}`.trim()}
data-prevent-toggle
data-docker-container-actions-surface={props.surface ?? 'docker-page'}
>
<div class={`inline-flex items-center justify-end gap-1 ${props.class ?? ''}`.trim()} data-prevent-toggle data-docker-container-actions-surface={props.surface ?? 'docker-page'}>
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>
Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) ||
(runningAction() !== null && runningAction() !== spec.action);
const disabled = () => Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) || (planningAction() !== null && planningAction() !== spec.action);
const Icon = iconForAction(spec.action);
return (
<button
type="button"
class={classForAction(spec.action)}
disabled={disabled()}
title={titleForAction(spec.action, spec.label)}
aria-label={titleForAction(spec.action, spec.label)}
data-docker-container-action={spec.action}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void executeLifecycleAction(spec.action);
}}
>
<Switch>
<Match when={runningAction() === spec.action}>
<Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</Match>
<Match when={true}>
<Icon class="h-3.5 w-3.5" aria-hidden="true" />
</Match>
</Switch>
<button type="button" class={`${buttonBaseClass} ${planningAction() === spec.action ? runningButtonClass : disabled() ? disabledButtonClass : enabledButtonClass}`} disabled={disabled()} title={titleForAction(spec.action, spec.label)} aria-label={titleForAction(spec.action, spec.label)} data-docker-container-action={spec.action} onMouseDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); void prepareLifecycleReview(spec.action); }}>
<Switch><Match when={planningAction() === spec.action}><Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" /></Match><Match when={true}><Icon class="h-3.5 w-3.5" aria-hidden="true" /></Match></Switch>
</button>
);
}}
</For>
<ActionReviewDialog
detail={reviewDetail()}
onClose={() => setReviewDetail(null)}
onChanged={async (detail) => {
setReviewDetail(detail);
const action = reviewAction();
if (!action || !['completed', 'failed', 'rejected', 'expired'].includes(detail.audit.state)) return;
try {
await props.onActionSettled?.({ action, actionId: detail.audit.id, resource: props.resource });
} catch {
notificationStore.warning('Action recorded. Refresh container inventory to see the latest state.');
}
}}
/>
</div>
);
};
@@ -55,6 +55,19 @@ vi.mock('@/api/resourceActions', () => ({
result: { success: true },
audit: {},
}),
getAction: vi.fn().mockResolvedValue({
audit: {
id: 'action-1',
createdAt: '2026-06-12T20:00:00Z',
updatedAt: '2026-06-12T20:00:00Z',
state: 'pending_approval',
decisionRevision: 0,
request: { requestId: 'request-1', resourceId: 'container-1', capabilityName: 'restart', reason: 'restart Docker container edge-web', requestedBy: 'ui:docker-page' },
plan: { actionId: 'action-1', requestId: 'request-1', allowed: true, requiresApproval: true, approvalPolicy: 'admin', rollbackAvailable: false, expiresAt: '2026-06-12T20:05:00Z', policyDecision: { version: 0, status: 'legacy_unknown', scope: { orgId: '', resourceId: '', capabilityName: '' }, authorities: [], approvalRequirement: { version: 0, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: false, requiresApproval: true } },
verificationOutcome: { status: 'unknown' },
},
events: [],
}),
},
}));
@@ -62,6 +75,7 @@ vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
@@ -722,7 +736,7 @@ describe('Docker native tables', () => {
);
});
it('runs Docker lifecycle row actions through the governed action API', async () => {
it('opens canonical review on the first Docker lifecycle click without auto-approving or executing', async () => {
const onLifecycleActionSettled = vi.fn();
renderInRouter(() => (
@@ -767,10 +781,9 @@ describe('Docker native tables', () => {
));
const restartButton = screen.getByRole('button', {
name: 'Restart edge-web through governed action',
name: 'Review restart for edge-web',
});
fireEvent.click(restartButton);
fireEvent.click(screen.getByRole('button', { name: 'Click again to restart edge-web' }));
await waitFor(() =>
expect(ResourceActionsAPI.planAction).toHaveBeenCalledWith(
@@ -781,18 +794,11 @@ describe('Docker native tables', () => {
}),
),
);
await waitFor(() =>
expect(ResourceActionsAPI.executeAction).toHaveBeenCalledWith(
'action-1',
expect.stringContaining('restart Docker container edge-web'),
),
);
expect(ResourceActionsAPI.decideAction).toHaveBeenCalledWith(
'action-1',
'approved',
expect.stringContaining('restart Docker container edge-web'),
);
expect(onLifecycleActionSettled).toHaveBeenCalledTimes(1);
expect(await screen.findByRole('dialog', { name: 'Restart' })).toBeInTheDocument();
expect(screen.getByText('Why Pulse allows this review')).toBeInTheDocument();
expect(ResourceActionsAPI.decideAction).not.toHaveBeenCalled();
expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled();
expect(onLifecycleActionSettled).not.toHaveBeenCalled();
});
it('shows disabled Docker lifecycle buttons with explicit unavailable reasons', () => {
@@ -0,0 +1,48 @@
import { For, createSignal, type Component } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
const scopeLabel = (scope: string): string => {
switch (scope) {
case 'policy_authorized_actions': return 'Only actions authorized by current server policy';
case 'capability_allowlisted_only': return 'Only capabilities on the server allowlist';
case 'outcome_truth_not_inferred': return 'Execution success is never presented as verified outcome truth';
case 'revocation_and_version_rotation_bound': return 'Activation stops after revocation or acknowledgement version rotation';
default: return scope.replace(/_/g, ' ');
}
};
export const PatrolAutopilotAcknowledgementDialog: Component<{ state: PatrolIntelligenceState }> = (props) => {
const [accepted, setAccepted] = createSignal(false);
const status = () => props.state.autopilotStatus();
const close = () => { setAccepted(false); props.state.setAutopilotDialogOpen(false); };
return (
<Dialog isOpen={props.state.autopilotDialogOpen()} onClose={close} ariaLabelledBy="autopilot-ack-title" ariaDescribedBy="autopilot-ack-description" closeOnBackdrop={!props.state.isUpdatingAutonomy()} panelClass="max-w-2xl">
<div class="flex max-h-[min(90vh,800px)] flex-col">
<header class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div><p class="text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">Explicit control change</p><h2 id="autopilot-ack-title" class="mt-1 text-xl font-semibold">Activate Autopilot</h2><p id="autopilot-ack-description" class="mt-2 text-sm text-muted">Pulse will record a server-owned version {status()?.currentVersion ?? 'current'} acknowledgement before requesting full mode.</p></div>
<Button variant="ghost" size="icon" aria-label="Close Autopilot acknowledgement" onClick={close}><XIcon class="h-5 w-5" /></Button>
</header>
<div class="overflow-y-auto px-5 py-4">
<div class="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<p class="font-semibold">Autopilot may execute eligible infrastructure actions without asking each time.</p>
<p class="mt-2">It remains bounded by approval floors, per-resource policy, the emergency stop, current licensing, and fresh execution-time authorization.</p>
</div>
<h3 class="mt-5 text-sm font-semibold">What this acknowledgement covers</h3>
<ul class="mt-2 space-y-2 text-sm"><For each={status()?.acceptedScope ?? ['policy_authorized_actions', 'capability_allowlisted_only', 'outcome_truth_not_inferred']}>{(scope) => <li class="flex gap-2"><span aria-hidden="true"></span><span>{scopeLabel(scope)}</span></li>}</For></ul>
<h3 class="mt-5 text-sm font-semibold">Truth and recovery limits</h3>
<ul class="mt-2 list-disc space-y-2 pl-5 text-sm text-muted">
<li>Verification can be confirmed, contradicted, inconclusive, or not attempted.</li>
<li>Pulse discloses whether evidence came from the executing agent or an independent observer.</li>
<li>You can revoke this acknowledgement. Version changes, expiry, revocation, policy drift, or reconnect races demote effective mode server-side.</li>
</ul>
<label class="mt-5 flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-surface-hover p-4 text-sm"><input type="checkbox" class="mt-1 h-4 w-4" checked={accepted()} onChange={(event) => setAccepted(event.currentTarget.checked)} /><span><strong>I understand and accept these Autopilot limits.</strong><span class="mt-1 block text-muted">This acknowledgement applies to my authenticated organization and identity.</span></span></label>
</div>
<footer class="flex flex-col-reverse gap-2 border-t border-border px-5 py-4 sm:flex-row sm:justify-end"><Button onClick={close}>Cancel</Button><Button variant="warningSolid" disabled={!accepted()} isLoading={props.state.isUpdatingAutonomy()} onClick={() => void props.state.acknowledgeAndActivateAutopilot()}>Record acknowledgement and activate</Button></footer>
</div>
</Dialog>
);
};
@@ -9,6 +9,7 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { CountdownTimer } from '@/components/patrol';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { Button } from '@/components/shared/Button';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
@@ -28,6 +29,7 @@ import type { PatrolConfigurationFailureInput } from './patrolInvestigationConte
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
import { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
import { PatrolAutopilotAcknowledgementDialog } from './PatrolAutopilotAcknowledgementDialog';
export { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
@@ -273,11 +275,23 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
Saving Patrol mode
</div>
</Show>
<Show when={state.requestedAutonomyLevel() !== state.autonomyLevel()}>
<div role="status" class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
Requested {PATROL_AUTONOMY_POLICY_PRESENTATION[state.requestedAutonomyLevel()].label}; effective mode is {selectedAutonomyPolicy().label}. Server status: {state.autopilotStatus()?.code.replace(/_/g, ' ') || 'unavailable'}.
</div>
</Show>
<Show when={state.autopilotStatus()?.active && state.autopilotStatus()?.acknowledgementId}>
<div class="mt-3 flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div><span class="font-semibold">Autopilot acknowledgement v{state.autopilotStatus()?.acknowledgementVersion}</span><span class="ml-2 text-muted">Active for this identity<Show when={state.autopilotStatus()?.expiresAt}> until {new Date(state.autopilotStatus()!.expiresAt!).toLocaleString()}</Show>.</span></div>
<Button size="sm" variant="dangerOutline" disabled={state.isUpdatingAutonomy()} onClick={() => void state.revokeAutopilot()}>Revoke Autopilot</Button>
</div>
</Show>
</>
);
return (
<div class="space-y-4">
<PatrolAutopilotAcknowledgementDialog state={state} />
<PageHeader
id="patrol-title"
description={headerMeta().description}
@@ -0,0 +1,27 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState';
import { PatrolAutopilotAcknowledgementDialog } from '../PatrolAutopilotAcknowledgementDialog';
afterEach(cleanup);
describe('PatrolAutopilotAcknowledgementDialog', () => {
it('requires explicit acceptance of the server acknowledgement version before activation', () => {
const activate = vi.fn();
const state = {
autopilotDialogOpen: () => true,
autopilotStatus: () => ({ code: 'acknowledgement_required', active: false, currentVersion: 1, acceptedScope: ['policy_authorized_actions', 'outcome_truth_not_inferred'], acceptedLimits: {} }),
isUpdatingAutonomy: () => false,
setAutopilotDialogOpen: vi.fn(),
acknowledgeAndActivateAutopilot: activate,
} as unknown as PatrolIntelligenceState;
render(() => <PatrolAutopilotAcknowledgementDialog state={state} />);
const activateButton = screen.getByRole('button', { name: 'Record acknowledgement and activate' });
expect(activateButton).toBeDisabled();
expect(screen.getByText(/version 1 acknowledgement/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox'));
expect(activateButton).toBeEnabled();
fireEvent.click(activateButton);
expect(activate).toHaveBeenCalledTimes(1);
});
});
@@ -1,12 +1,15 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { AIAPI } from '@/api/ai';
import {
createPatrolAutopilotAcknowledgement,
getPatrolAutonomySettings,
getPatrolRunHistory,
getPatrolStatus,
revokePatrolAutopilotAcknowledgement,
triggerPatrolRun,
updatePatrolAutonomySettings,
type PatrolAutonomyLevel,
type PatrolAutopilotStatus,
type PatrolRunRecord,
type PatrolRuntimeState,
type PatrolStatus,
@@ -229,6 +232,9 @@ export function usePatrolIntelligenceState() {
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [isManualRefreshRunning, setIsManualRefreshRunning] = createSignal(false);
const [autonomyLevel, setAutonomyLevel] = createSignal<PatrolAutonomyLevel>('monitor');
const [requestedAutonomyLevel, setRequestedAutonomyLevel] = createSignal<PatrolAutonomyLevel>('monitor');
const [autopilotStatus, setAutopilotStatus] = createSignal<PatrolAutopilotStatus | null>(null);
const [autopilotDialogOpen, setAutopilotDialogOpen] = createSignal(false);
const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false);
const [activityRefreshTrigger, setActivityRefreshTrigger] = createSignal(0);
const [manualRunRequested, setManualRunRequested] = createSignal(false);
@@ -577,13 +583,10 @@ export function usePatrolIntelligenceState() {
try {
const settings = await getPatrolAutonomySettings();
if (!settings) return;
const effectiveSettings = resolvePatrolAutonomySettingsForSave({
level: settings.autonomy_level,
fullModeUnlocked: settings.full_mode_unlocked,
autoFixLocked: autoFixLocked(),
});
setAutonomyLevel(effectiveSettings.autonomyLevel);
setFullModeUnlocked(effectiveSettings.fullModeUnlocked);
setRequestedAutonomyLevel(settings.requested_autonomy_level);
setAutonomyLevel(settings.effective_autonomy_level);
setAutopilotStatus(settings.autopilot_acknowledgement);
setFullModeUnlocked(settings.autopilot_acknowledgement.active);
setInvestigationBudget(settings.investigation_budget);
setInvestigationTimeout(settings.investigation_timeout_sec);
} catch (err) {
@@ -596,28 +599,26 @@ export function usePatrolIntelligenceState() {
const controlLocked = autoFixLocked();
if (controlLocked && level !== 'monitor') return;
if (level === 'full') {
setAutopilotDialogOpen(true);
return;
}
const previousLevel = autonomyLevel();
const previousFullModeUnlocked = fullModeUnlocked();
const effectiveSettings = resolvePatrolAutonomySettingsForSave({
level,
fullModeUnlocked: level === 'full',
autoFixLocked: controlLocked,
});
const shouldRecordPatrolControlStarter =
!controlLocked &&
(effectiveSettings.autonomyLevel !== previousLevel ||
effectiveSettings.fullModeUnlocked !== previousFullModeUnlocked);
setAutonomyLevel(effectiveSettings.autonomyLevel);
setFullModeUnlocked(effectiveSettings.fullModeUnlocked);
level !== previousLevel;
setIsUpdatingAutonomy(true);
try {
await updatePatrolAutonomySettings({
autonomy_level: effectiveSettings.autonomyLevel,
full_mode_unlocked: effectiveSettings.fullModeUnlocked,
const response = await updatePatrolAutonomySettings({
autonomy_level: level,
investigation_budget: investigationBudget(),
investigation_timeout_sec: investigationTimeout(),
});
setRequestedAutonomyLevel(response.settings.requested_autonomy_level);
setAutonomyLevel(response.settings.effective_autonomy_level);
setAutopilotStatus(response.settings.autopilot_acknowledgement);
setFullModeUnlocked(response.settings.autopilot_acknowledgement.active);
if (shouldRecordPatrolControlStarter) {
await recordPatrolControlStarterActivity();
await loadVisiblePatrolData();
@@ -625,13 +626,58 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to update autonomy:', err);
setAutonomyLevel(previousLevel);
setFullModeUnlocked(previousFullModeUnlocked);
notificationStore.error((err as Error).message || 'Failed to update Patrol mode');
} finally {
setIsUpdatingAutonomy(false);
}
}
async function acknowledgeAndActivateAutopilot() {
if (isUpdatingAutonomy() || autoFixLocked()) return;
setIsUpdatingAutonomy(true);
const acknowledgementId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `patrol-autopilot-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
const acknowledgement = await createPatrolAutopilotAcknowledgement(acknowledgementId);
const response = await updatePatrolAutonomySettings({
autonomy_level: 'full',
acknowledgement_id: acknowledgement.acknowledgement.acknowledgementId || acknowledgementId,
investigation_budget: investigationBudget(),
investigation_timeout_sec: investigationTimeout(),
});
setRequestedAutonomyLevel(response.settings.requested_autonomy_level);
setAutonomyLevel(response.settings.effective_autonomy_level);
setAutopilotStatus(response.settings.autopilot_acknowledgement);
setFullModeUnlocked(response.settings.autopilot_acknowledgement.active);
setAutopilotDialogOpen(false);
await recordPatrolControlStarterActivity();
await loadVisiblePatrolData();
notificationStore.success('Autopilot acknowledgement recorded and mode activated.');
} catch (err) {
await loadAutonomySettings();
notificationStore.error((err as Error).message || 'Autopilot could not be activated.');
} finally {
setIsUpdatingAutonomy(false);
}
}
async function revokeAutopilot() {
const acknowledgementId = autopilotStatus()?.acknowledgementId;
if (!acknowledgementId || isUpdatingAutonomy()) return;
setIsUpdatingAutonomy(true);
try {
await revokePatrolAutopilotAcknowledgement(acknowledgementId);
await loadAutonomySettings();
notificationStore.success('Autopilot acknowledgement revoked. Effective mode refreshed.');
} catch (err) {
notificationStore.error((err as Error).message || 'Autopilot acknowledgement could not be revoked.');
} finally {
setIsUpdatingAutonomy(false);
}
}
function handleAssistantFindingHandoff(findingId: string) {
setAssistantHandoffFindingId(findingId.trim());
}
@@ -725,6 +771,7 @@ export function usePatrolIntelligenceState() {
await Promise.all([
aiIntelligenceStore.loadDashboardData(),
refetchPatrolStatus(),
loadAutonomySettings(),
]);
if (requestId === refreshRequestId) {
clearPatrolLoadError();
@@ -955,6 +1002,10 @@ export function usePatrolIntelligenceState() {
activityRefreshTrigger,
assistantHandoffFindingId,
autonomyLevel,
requestedAutonomyLevel,
autopilotStatus,
autopilotDialogOpen,
acknowledgeAndActivateAutopilot,
autoFixCapabilityBlock,
autoFixLocked,
blockedAt,
@@ -980,6 +1031,7 @@ export function usePatrolIntelligenceState() {
isTogglingPatrol,
isTriggeringPatrol,
isUpdatingAutonomy,
revokeAutopilot,
licenseRequired,
loadAllData,
licenseRuntimeIdentity,
@@ -1008,6 +1060,7 @@ export function usePatrolIntelligenceState() {
setActiveTab,
setFindingsFilterOverride,
setFullModeUnlocked,
setAutopilotDialogOpen,
setSelectedRun,
setFindingScrollTimer: (timer: ReturnType<typeof setTimeout> | undefined) => {
findingScrollTimerRef = timer;
+70
View File
@@ -0,0 +1,70 @@
import { For, Show, createEffect, createSignal } from 'solid-js';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button } from '@/components/shared/Button';
import { PageHeader } from '@/components/shared/PageHeader';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
import { formatActionName } from '@/features/actions/actionPresentation';
import type { ActionAuditRecord, ActionDetailResponse, ActionInboxView } from '@/types/actionAudit';
import { formatRelativeTime } from '@/utils/format';
export function Actions() {
const [view, setView] = createSignal<ActionInboxView>('pending');
const [selected, setSelected] = createSignal<ActionDetailResponse | null>(null);
const [detailError, setDetailError] = createSignal('');
const [actions, setActions] = createSignal<ActionAuditRecord[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const loadActions = async () => {
setLoading(true);
setLoadError('');
try {
const response = await ResourceActionsAPI.listActions(view());
setActions(response.actions);
} catch (cause) {
setActions([]);
setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.');
} finally {
setLoading(false);
}
};
createEffect(() => {
view();
void loadActions();
});
const openAction = async (record: ActionAuditRecord) => {
setDetailError('');
try { setSelected(await ResourceActionsAPI.getAction(record.id)); }
catch (cause) { setDetailError(cause instanceof Error ? cause.message : 'Action details are unavailable.'); }
};
return (
<div class="mx-auto w-full max-w-6xl space-y-5 px-3 py-4 sm:px-5">
<PageHeader title="Actions" description="Review every pending change and its recorded outcome in one place." />
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="tablist" aria-label="Action views" class="inline-flex rounded-lg border border-border bg-surface p-1">
<button role="tab" aria-selected={view() === 'pending'} class={`rounded px-4 py-2 text-sm font-medium ${view() === 'pending' ? 'bg-blue-600 text-white' : 'text-muted hover:text-base-content'}`} onClick={() => setView('pending')}>Needs attention</button>
<button role="tab" aria-selected={view() === 'settled'} class={`rounded px-4 py-2 text-sm font-medium ${view() === 'settled' ? 'bg-blue-600 text-white' : 'text-muted hover:text-base-content'}`} onClick={() => setView('settled')}>History</button>
</div>
<Button aria-label="Refresh actions" onClick={() => void loadActions()} isLoading={loading()}><RefreshCwIcon class="mr-2 h-4 w-4" />Refresh</Button>
</div>
<Show when={detailError()}><div role="alert" class="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/40 dark:text-amber-200">{detailError()}</div></Show>
<Show when={loadError()}><div role="alert" class="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950/40 dark:text-red-200"><p class="font-medium">Actions could not be loaded.</p><p class="mt-1">{loadError()}</p><Button class="mt-3" onClick={() => void loadActions()}>Try again</Button></div></Show>
<Show when={!loading() && !loadError() && actions().length === 0}>
<div data-testid="actions-calm-state" class="rounded-xl border border-dashed border-border bg-surface p-8 text-center"><h2 class="text-lg font-semibold">{view() === 'pending' ? 'No actions need attention' : 'No action history yet'}</h2><p class="mt-2 text-sm text-muted">{view() === 'pending' ? 'Pulse will place proposed or in-progress changes here for review.' : 'Completed, rejected, expired, and failed actions will appear here.'}</p></div>
</Show>
<Show when={actions().length > 0}>
<ul class="space-y-3" aria-label={view() === 'pending' ? 'Actions needing attention' : 'Action history'}>
<For each={actions()}>{(action) => <li><button type="button" class="w-full rounded-xl border border-border bg-surface p-4 text-left transition hover:border-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500" onClick={() => void openAction(action)}><div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div class="min-w-0"><div class="font-semibold">{formatActionName(action.request.capabilityName)}</div><div class="mt-1 break-all text-sm text-muted">{action.request.resourceId}</div><p class="mt-2 text-sm">{action.request.reason}</p></div><div class="shrink-0 text-left sm:text-right"><span class="inline-flex rounded-full border border-border-subtle bg-surface-hover px-2.5 py-1 text-xs font-medium">{formatActionName(action.state)}</span><div class="mt-2 text-xs text-muted">{formatRelativeTime(action.updatedAt)}</div></div></div></button></li>}</For>
</ul>
</Show>
<ActionReviewDialog detail={selected()} onClose={() => setSelected(null)} onChanged={async (detail) => { setSelected(detail); await loadActions(); }} />
</div>
);
}
export default Actions;
@@ -21,6 +21,8 @@ describe('navigation routing helpers', () => {
expect(getActiveTabForPath('/ceph')).toBeNull();
expect(getActiveTabForPath('/recovery')).toBeNull();
expect(getActiveTabForPath('/alerts/open')).toBe('alerts');
expect(getActiveTabForPath('/actions')).toBe('actions');
expect(getActiveTabForPath('/actions/history')).toBe('actions');
expect(getActiveTabForPath('/patrol')).toBe('ai');
expect(getActiveTabForPath('/ai')).toBeNull();
expect(getActiveTabForPath('/operations')).toBeNull();
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { APP_SHELL_ROUTE_PRELOAD_PATHS } from '../routePreload';
import { PATROL_PATH, buildProxmoxPath, buildStandalonePath } from '../resourceLinks';
import { ACTIONS_PATH, PATROL_PATH, buildProxmoxPath, buildStandalonePath } from '../resourceLinks';
describe('route preloading', () => {
it('keeps Proxmox in the authenticated app-shell preload set', () => {
@@ -13,6 +13,7 @@ describe('route preloading', () => {
buildProxmoxPath(),
buildStandalonePath(),
PATROL_PATH,
ACTIONS_PATH,
'/alerts',
'/settings',
]);
@@ -1,5 +1,6 @@
import {
DOCKER_PATH,
ACTIONS_PATH,
KUBERNETES_PATH,
PATROL_PATH,
PROXMOX_PATH,
@@ -16,6 +17,7 @@ export type AppTabId =
| 'truenas'
| 'vmware'
| 'alerts'
| 'actions'
| 'ai'
| 'settings';
@@ -29,6 +31,7 @@ export function getActiveTabForPath(path: string): ActiveAppTabId {
if (path.startsWith(VMWARE_PATH)) return 'vmware';
if (path.startsWith(STANDALONE_PATH)) return 'standalone';
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith(ACTIONS_PATH)) return 'actions';
if (path.startsWith(PATROL_PATH)) return 'ai';
if (path.startsWith('/settings')) return 'settings';
return null;
@@ -30,6 +30,7 @@ export const VMWARE_PATH = '/vmware';
export const VMWARE_DEFAULT_TAB = 'overview';
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
export const PATROL_PATH = '/patrol';
export const ACTIONS_PATH = '/actions';
export const PATROL_CONTROL_ANCHOR = 'patrol-control';
export const PATROL_CONTROL_STARTER_QUERY_PARAM = 'patrolControlStarter';
export const PATROL_CONTROL_STARTER = 'patrol_control';
@@ -1,5 +1,6 @@
import {
buildProxmoxPath,
ACTIONS_PATH,
buildStandalonePath,
DOCKER_PATH,
KUBERNETES_PATH,
@@ -26,6 +27,7 @@ export const APP_SHELL_ROUTE_PRELOAD_PATHS = [
ROOT_PROXMOX_PATH,
ROOT_STANDALONE_PATH,
PATROL_PATH,
ACTIONS_PATH,
ALERTS_PATH,
SETTINGS_PATH,
] as const;
@@ -75,6 +77,11 @@ const ROUTE_PRELOADERS: readonly RoutePreloader[] = [
matches: (route) => route === ALERTS_PATH || route.startsWith(`${ALERTS_PATH}/`),
preload: () => import('@/pages/Alerts').then(() => undefined),
},
{
id: 'actions',
matches: (route) => route === ACTIONS_PATH || route.startsWith(`${ACTIONS_PATH}/`),
preload: () => import('@/pages/Actions').then(() => undefined),
},
{
id: 'patrol',
matches: (route) => route === PATROL_PATH || route.startsWith(`${PATROL_PATH}/`),
+228 -13
View File
@@ -1,7 +1,23 @@
export type ActionAuditState =
'planned' | 'pending_approval' | 'approved' | 'rejected' | 'executing' | 'completed' | 'failed';
| 'planned'
| 'pending_approval'
| 'approved'
| 'rejected'
| 'expired'
| 'executing'
| 'completed'
| 'failed';
export type ActionAuditApprovalPolicy = 'none' | 'dry_run_only' | 'admin' | 'mfa' | string;
export type ActionAuditApprovalPolicy = 'none' | 'dry_run_only' | 'admin' | 'mfa';
export type ActionActorKind = 'user' | 'api_token' | 'service' | 'policy';
export interface ActionActor {
subjectId: string;
kind: ActionActorKind;
credentialId: string;
orgId: string;
}
export interface ActionAuditRequest {
requestId: string;
@@ -10,9 +26,75 @@ export interface ActionAuditRequest {
params?: Record<string, unknown>;
reason: string;
requestedBy: string;
actor?: ActionActor;
}
export type ResourceActionRequest = ActionAuditRequest;
export type ResourceActionRequest = Omit<ActionAuditRequest, 'actor'>;
export interface ActionApprovalRequirement {
version: number;
floor: ActionAuditApprovalPolicy;
quorum: number;
disallowRequester: boolean;
}
export type ActionPolicyDecisionStatus = 'resolved' | 'legacy_unknown';
export type ActionPolicyAuthorityKind =
| 'capability_registry'
| 'tenant_patrol_policy'
| 'resource_operator_policy';
export type ActionPolicyAuthorityStatus = 'consulted' | 'unavailable' | 'not_found';
export type ActionPolicyReasonCode =
| 'capability_approval_none'
| 'capability_approval_admin'
| 'capability_approval_mfa'
| 'capability_dry_run_only'
| 'capability_auto_never'
| 'capability_auto_low_risk'
| 'capability_auto_elevated'
| 'tenant_policy_unavailable'
| 'tenant_emergency_stop'
| 'tenant_mode_monitor'
| 'tenant_mode_assisted'
| 'tenant_mode_full'
| 'tenant_mode_unknown'
| 'tenant_full_mode_locked'
| 'tenant_full_mode_unlocked'
| 'resource_policy_unavailable'
| 'resource_policy_missing'
| 'resource_never_auto_remediate'
| 'resource_capability_allowed'
| 'resource_capability_not_allowed'
| 'resource_window_open'
| 'resource_window_closed';
export interface ActionPolicyDecisionScope {
orgId: string;
resourceId: string;
capabilityName: string;
}
export interface ActionPolicyAuthorityFactor {
kind: ActionPolicyAuthorityKind;
sourceId: string;
revision?: string;
status: ActionPolicyAuthorityStatus;
scope: ActionPolicyDecisionScope;
approvalFloor?: ActionAuditApprovalPolicy;
reasonCodes: ActionPolicyReasonCode[];
}
export interface ActionPolicyDecisionProvenance {
version: number;
status: ActionPolicyDecisionStatus;
decisionId?: string;
actionId?: string;
scope: ActionPolicyDecisionScope;
authorities: ActionPolicyAuthorityFactor[];
approvalRequirement: ActionApprovalRequirement;
planningAllowed: boolean;
requiresApproval: boolean;
}
export interface ActionAuditPreflight {
target?: string;
@@ -31,6 +113,7 @@ export interface ActionAuditPlan {
allowed: boolean;
requiresApproval: boolean;
approvalPolicy: ActionAuditApprovalPolicy;
approvalRequirement?: ActionApprovalRequirement;
predictedBlastRadius?: string[];
rollbackAvailable: boolean;
message?: string;
@@ -38,6 +121,7 @@ export interface ActionAuditPlan {
expiresAt?: string;
resourceVersion?: string;
policyVersion?: string;
policyDecision?: ActionPolicyDecisionProvenance;
planHash?: string;
preflight?: ActionAuditPreflight;
}
@@ -46,14 +130,10 @@ export interface ActionAuditApprovalRecord {
actor: string;
method: string;
timestamp: string;
outcome: 'approved' | 'rejected' | string;
outcome: 'approved' | 'rejected';
reason?: string;
}
// ActionVerificationResult mirrors the Go type that records the outcome of
// the broker's post-dispatch read-after-write check. It is best-effort:
// when no verification command is derivable for the action class, ran is
// false and the rest of the fields are empty rather than fabricated.
export interface ActionVerificationResult {
ran: boolean;
command?: string;
@@ -63,17 +143,112 @@ export interface ActionVerificationResult {
note?: string;
}
export type ActionExecutionStatus = 'not_run' | 'succeeded' | 'failed' | 'inconclusive';
export type ActionVerificationTruthStatus =
| 'not_attempted'
| 'confirmed'
| 'contradicted'
| 'inconclusive';
export type ActionEvidenceClass = 'none' | 'agent_attested' | 'independent';
export interface ActionEvidenceRef {
id: string;
kind: string;
digest: string;
}
export interface ActionEvidence {
version: number;
id: string;
observerId: string;
observerKind: string;
observerTrustDomain: string;
executorTrustDomain: string;
method: string;
subjectId: string;
observedAt: string;
receivedAt: string;
reasonCode?: string;
summary?: string;
refs?: ActionEvidenceRef[];
digest: string;
}
export interface ActionExecutionTruth {
status: ActionExecutionStatus;
reasonCode?: string;
summary?: string;
}
export interface ActionVerificationTruth {
status: ActionVerificationTruthStatus;
evidenceClass: ActionEvidenceClass;
reasonCode?: string;
summary?: string;
evidence?: ActionEvidence[];
}
export type ActionCompensationSupport = 'unavailable' | 'declared';
export type ActionCompensationStatus =
| 'not_available'
| 'not_needed'
| 'not_attempted'
| 'running'
| 'succeeded'
| 'failed'
| 'inconclusive';
export interface ActionRestoredState {
subjectId: string;
expectedDigest: string;
observedDigest: string;
observedAt: string;
}
export interface ActionCompensationTruth {
support: ActionCompensationSupport;
strategy?: string;
trigger?: string;
status: ActionCompensationStatus;
reasonCode?: string;
summary?: string;
attemptId?: string;
stepId?: string;
startedAt?: string;
completedAt?: string;
evidence?: ActionEvidence[];
execution?: ActionExecutionTruth;
verification?: ActionVerificationTruth;
restoredState?: ActionRestoredState;
}
export interface ActionResultV2 {
version: 2;
execution: ActionExecutionTruth;
verification: ActionVerificationTruth;
compensation: ActionCompensationTruth;
}
export interface ActionAuditExecutionResult {
success: boolean;
output?: string;
errorMessage?: string;
verification?: ActionVerificationResult;
actionResultV2?: ActionResultV2;
}
export type ActionVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed' | string;
export type ActionVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed';
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:'
| 'policy_authorization_expired:'
| 'policy_authorization_invalid:'
| 'policy_authorization_revoked:'
| 'action_emergency_stop:'
| 'action_replan_required:';
export interface ActionVerificationOutcome {
status: ActionVerificationStatus;
@@ -85,6 +260,7 @@ export interface ActionAuditRecord {
createdAt: string;
updatedAt: string;
state: ActionAuditState;
decisionRevision?: number;
request: ActionAuditRequest;
plan: ActionAuditPlan;
origin?: ActionAuditOrigin;
@@ -101,9 +277,6 @@ export interface ActionAuditOrigin {
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;
@@ -118,6 +291,11 @@ export interface PendingActionsResponse {
count: number;
}
export type ActionInboxView = 'pending' | 'settled';
export interface ActionInboxResponse extends PendingActionsResponse {
view: ActionInboxView;
}
export interface ActionAuditListResponse {
audits: ActionAuditRecord[];
count: number;
@@ -125,6 +303,43 @@ export interface ActionAuditListResponse {
available: boolean;
}
export interface ActionLifecycleEvent {
actionId: string;
timestamp: string;
kind: 'transition' | 'decision' | 'legacy';
state: ActionAuditState;
decisionRevision?: number;
decision?: ActionAuditApprovalRecord;
actor?: string;
message?: string;
}
export type ActionDispatchState = 'queued' | 'claimed' | 'receipt_pending' | 'receipt_recorded';
export interface ActionDispatchAttempt {
id: string;
actionId: string;
state: ActionDispatchState;
createdAt: string;
updatedAt: string;
leaseOwner?: string;
leaseExpiresAt?: string;
dispatchCount: number;
}
export interface ActionDispatchReceipt {
attemptId: string;
actionId: string;
transportRequestId: string;
receivedAt: string;
}
export interface ActionDetailResponse {
audit: ActionAuditRecord;
events: ActionLifecycleEvent[];
attempt?: ActionDispatchAttempt;
receipt?: ActionDispatchReceipt;
}
export interface ActionDecisionResponse {
actionId: string;
state: ActionAuditState;
@@ -156,7 +156,7 @@ describe('actionAuditPresentation', () => {
},
}),
).toMatchObject({
label: 'Verification confirmed',
label: 'Legacy check passed (source unclassified)',
evidenceSummary: 'Readback matched the intended running state.',
});
expect(
@@ -186,7 +186,7 @@ describe('actionAuditPresentation', () => {
expect(
getActionAuditVerificationOutcomePresentation({
verificationOutcome: {
status: 'needs_review',
status: 'needs_review' as never,
},
}),
).toMatchObject({ label: 'Verification outcome recorded' });
@@ -8,6 +8,7 @@ import {
setApiToken,
setOrgID,
} from '@/utils/apiClient';
import { ResourceActionsAPI } from '@/api/resourceActions';
const mockFetch = vi.fn();
@@ -69,6 +70,39 @@ describe('apiClient org context', () => {
expect(headers['X-Pulse-Org-ID']).toBe('tenant-ledger');
});
it('preserves authenticated organization context across durable action operations', async () => {
mockFetch.mockImplementation(async () =>
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
setOrgID('tenant-actions');
await ResourceActionsAPI.listActions('pending', 20);
await ResourceActionsAPI.listActions('settled', 20);
await ResourceActionsAPI.getAction('action/one');
await ResourceActionsAPI.decideAction('action/one', 'approved', 'Reviewed by operator');
await ResourceActionsAPI.executeAction('action/one', 'Execute the reviewed plan');
expect(mockFetch.mock.calls.map(([url]) => String(url))).toEqual([
'/api/actions?view=pending&limit=20',
'/api/actions?view=settled&limit=20',
'/api/actions/action%2Fone',
'/api/actions/action%2Fone/decision',
'/api/actions/action%2Fone/execute',
]);
for (const [, options] of mockFetch.mock.calls as [string, RequestInit][]) {
const headers = options.headers as Record<string, string>;
expect(headers['X-Pulse-Org-ID']).toBe('tenant-actions');
}
const mutationBodies = mockFetch.mock.calls.slice(3).map(([, options]) =>
JSON.parse(String((options as RequestInit).body)),
);
expect(mutationBodies).toEqual([
{ outcome: 'approved', reason: 'Reviewed by operator' },
{ reason: 'Execute the reviewed plan' },
]);
expect(mutationBodies.every((body) => !('orgId' in body))).toBe(true);
});
it('preserves hosted org context on structured commercial errors', async () => {
mockFetch.mockResolvedValue(
new Response(
@@ -2,7 +2,6 @@ import type {
ActionAuditRefusalPrefix,
ActionAuditRecord,
ActionAuditState,
ActionVerificationOutcome,
ActionVerificationResult,
} from '@/types/actionAudit';
@@ -55,6 +54,11 @@ const ACTION_STATE_PRESENTATION: Record<ActionAuditState, ActionAuditStatePresen
className:
'bg-red-100 text-red-800 border-red-200 dark:bg-red-900 dark:text-red-200 dark:border-red-700',
},
expired: {
label: 'Expired',
className:
'bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900 dark:text-amber-200 dark:border-amber-700',
},
executing: {
label: 'Executing',
className:
@@ -110,6 +114,31 @@ const ACTION_REFUSAL_PRESENTATION: Record<
className:
'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300',
},
'policy_authorization_expired:': {
label: 'Policy authority expired',
detail: 'Pulse refused dispatch because the server authorization lease expired.',
className: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
},
'policy_authorization_invalid:': {
label: 'Policy authority invalid',
detail: 'Pulse refused dispatch because current server policy authority could not be validated.',
className: 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300',
},
'policy_authorization_revoked:': {
label: 'Policy authority revoked',
detail: 'Pulse refused dispatch because current server policy authority was revoked.',
className: 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300',
},
'action_emergency_stop:': {
label: 'Emergency stop active',
detail: 'Pulse refused dispatch because the action emergency stop is active.',
className: 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300',
},
'action_replan_required:': {
label: 'New review required',
detail: 'Pulse refused dispatch because current policy requires a new plan and review.',
className: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
},
};
const ACTION_REFUSAL_PREFIXES = Object.keys(
@@ -126,8 +155,8 @@ const VERIFICATION_OUTCOME_PRESENTATION: Record<
className: 'border-border bg-surface text-base-content',
},
verified: {
label: 'Verification confirmed',
detail: 'Pulse confirmed the intended state after execution.',
label: 'Legacy check passed (source unclassified)',
detail: 'This older record does not identify an evidence source. Do not treat it as independent confirmation.',
className:
'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300',
},
@@ -233,6 +262,17 @@ export const getActionAuditResultPresentation = (
};
}
const truth = result.actionResultV2?.execution;
if (truth) {
const presentation = {
succeeded: { kind: 'success' as const, label: 'Execution succeeded', className: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200' },
failed: { kind: 'failure' as const, label: 'Execution failed', className: 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300' },
not_run: { kind: 'failure' as const, label: 'Execution did not run', className: 'border-border bg-surface text-base-content' },
inconclusive: { kind: 'failure' as const, label: 'Execution inconclusive', className: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300' },
}[truth.status];
return { ...presentation, detail: truth.summary?.trim() || undefined, reasonLabel: truth.reasonCode ? formatActionCapabilityLabel(truth.reasonCode) : undefined };
}
if (result.success) {
return {
kind: 'success',
@@ -252,9 +292,37 @@ export const getActionAuditResultPresentation = (
};
export const getActionAuditVerificationOutcomePresentation = (
audit: Pick<ActionAuditRecord, 'verificationOutcome'>,
audit: {
verificationOutcome?: { status?: string; evidenceSummary?: string };
result?: ActionAuditRecord['result'];
},
): ActionAuditVerificationOutcomePresentation | undefined => {
const outcome: ActionVerificationOutcome | undefined = audit.verificationOutcome;
const truth = audit.result?.actionResultV2?.verification;
if (truth) {
const source = truth.evidenceClass === 'independent' ? 'Independent observer' : truth.evidenceClass === 'agent_attested' ? 'Executing agent (agent-attested)' : 'No evidence source';
const statusCopy = {
confirmed: truth.evidenceClass === 'independent'
? ['Confirmed by independent observer', 'An observer in a different trust domain confirmed the intended state.']
: truth.evidenceClass === 'agent_attested'
? ['Confirmed by executing agent', 'The same agent trust domain that executed the action reported the intended state.']
: ['Confirmation lacks an evidence source', 'The record says confirmed but provides no evidence source; do not treat it as independently verified.'],
contradicted: ['Outcome contradicted', 'Observed evidence contradicted the intended state.'],
inconclusive: ['Outcome inconclusive', 'Available evidence could not establish the intended state.'],
not_attempted: ['Outcome not verified', 'No outcome verification was attempted.'],
} as const;
const [label, detail] = statusCopy[truth.status];
return {
label,
detail,
evidenceSummary: `${truth.summary?.trim() || 'No additional verification summary.'} Source: ${source}.`,
className: truth.status === 'confirmed'
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300'
: truth.status === 'contradicted'
? 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300'
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
};
}
const outcome = audit.verificationOutcome;
const status = (outcome?.status || '').trim().toLowerCase();
if (!status) return undefined;
@@ -280,4 +348,5 @@ export const getActionAuditVerification = (
export const shouldRenderActionAuditVerification = (
audit: Pick<ActionAuditRecord, 'verification' | 'result'>,
): boolean => getActionAuditVerification(audit)?.ran === true;
): boolean =>
audit.result?.actionResultV2 === undefined && getActionAuditVerification(audit)?.ran === true;
@@ -12,6 +12,7 @@ export interface AssistantPageContextPresentation {
const VIEW_LABELS: Record<Exclude<ActiveAppTabId, null>, string> = {
alerts: 'Alerts',
actions: 'Actions',
ai: 'Patrol',
docker: 'Docker',
kubernetes: 'Kubernetes',
@@ -358,7 +358,24 @@ async function mockMonitorFirstPatrolWorkbench(
contentType: "application/json",
body: JSON.stringify({
autonomy_level: "monitor",
requested_autonomy_level: "monitor",
effective_autonomy_level: "monitor",
full_mode_unlocked: false,
autopilot_acknowledgement: {
code: "not_requested",
active: false,
currentVersion: 1,
acceptedScope: [],
acceptedLimits: {
policyAllowlistRequired: true,
emergencyStopHonored: true,
approvalFloorsHonored: true,
verificationReconciledWhenSupported: true,
evidenceClassDisclosed: true,
inconclusiveOutcomeAllowed: true,
executionSuccessIsNotOutcomeTruth: true,
},
},
investigation_budget: 15,
investigation_timeout_sec: 300,
}),
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = { authStorageStatePath: string };
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => use(authStorageStatePath),
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(__dirname, "..", "..", "tmp", "playwright-auth", `actions-inbox-${workerInfo.project.name}.json`);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try { await use(storageStatePath); } finally { fs.rmSync(storageStatePath, { force: true }); }
}, { scope: "worker" }],
});
const scope = { orgId: "org-1", resourceId: "docker:container:edge", capabilityName: "restart" };
const requirement = { version: 1, floor: "admin", quorum: 1, disallowRequester: false };
const action = {
id: "action-1", createdAt: "2026-07-12T00:00:00Z", updatedAt: "2026-07-12T00:01:00Z", state: "pending_approval", decisionRevision: 0,
request: { requestId: "request-1", resourceId: scope.resourceId, capabilityName: scope.capabilityName, reason: "Recover the edge proxy", requestedBy: "pulse_patrol", actor: { subjectId: "patrol", kind: "service", credentialId: "patrol", orgId: "org-1" } },
plan: { actionId: "action-1", requestId: "request-1", allowed: true, requiresApproval: true, approvalPolicy: "admin", approvalRequirement: requirement, rollbackAvailable: false, plannedAt: "2026-07-12T00:00:00Z", expiresAt: "2099-07-12T00:10:00Z", resourceVersion: "resource:sha256:one", policyVersion: "policy:sha256:one", planHash: "sha256:plan", policyDecision: { version: 1, status: "resolved", decisionId: "policy-decision:sha256:one", actionId: "action-1", scope, approvalRequirement: requirement, planningAllowed: true, requiresApproval: true, authorities: [{ kind: "capability_registry", sourceId: "capability-registry:restart", revision: "policy:sha256:one", status: "consulted", scope, approvalFloor: "admin", reasonCodes: ["capability_approval_admin", "capability_auto_low_risk"] }, { kind: "resource_operator_policy", sourceId: "resource-operator-policy:docker:container:edge", revision: "resource-policy:sha256:one", status: "consulted", scope, approvalFloor: "admin", reasonCodes: ["resource_capability_allowed", "resource_window_open"] }] } },
verificationOutcome: { status: "unknown" },
};
test("Actions inbox exposes the canonical decision packet and durable calm history", async ({ page }, testInfo) => {
await page.route("**/api/actions?*", async (route) => {
const view = new URL(route.request().url()).searchParams.get("view");
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(view === "pending" ? { view, actions: [action], count: 1 } : { view, actions: [], count: 0 }) });
});
await page.route("**/api/actions/action-1", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ audit: action, events: [] }) }));
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Actions" })).toBeVisible();
await page.getByRole("button", { name: /Restart.*docker:container:edge/ }).click();
await expect(page.getByRole("dialog", { name: "Restart" })).toBeVisible();
await expect(page.getByText("Capability safety policy")).toBeVisible();
await expect(page.getByText("Policy for this resource")).toBeVisible();
await expect(page.getByText("This records planning-time policy evidence. Pulse checks current authority again before execution.")).toBeVisible();
await testInfo.attach("canonical-action-decision-packet", { body: await page.screenshot(), contentType: "image/png" });
await page.getByRole("button", { name: "Close action review" }).click();
await page.getByRole("tab", { name: "History" }).click();
await expect(page.getByTestId("actions-calm-state")).toContainText("No action history yet");
});
test("Actions inbox gives a recoverable error without presenting stale authority", async ({ page }, testInfo) => {
await page.route("**/api/actions?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "action store unavailable" }) }));
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Actions could not be loaded");
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
await expect(page.getByTestId("actions-calm-state")).toHaveCount(0);
await testInfo.attach("actions-recoverable-error", { body: await page.screenshot(), contentType: "image/png" });
});
@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = { authStorageStatePath: string };
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => use(authStorageStatePath),
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(__dirname, "..", "..", "tmp", "playwright-auth", `autopilot-ack-${workerInfo.project.name}.json`);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try { await use(storageStatePath); } finally { fs.rmSync(storageStatePath, { force: true }); }
}, { scope: "worker" }],
});
const acceptedLimits = { policyAllowlistRequired: true, emergencyStopHonored: true, approvalFloorsHonored: true, verificationReconciledWhenSupported: true, evidenceClassDisclosed: true, inconclusiveOutcomeAllowed: true, executionSuccessIsNotOutcomeTruth: true };
const requiredStatus = { code: "acknowledgement_required", active: false, currentVersion: 1, acceptedScope: ["policy_authorized_actions", "capability_allowlisted_only", "outcome_truth_not_inferred"], acceptedLimits };
test("Autopilot records a versioned server acknowledgement before effective full mode and supports revocation", async ({ page }, testInfo) => {
let active = false;
await page.route("**/api/license/runtime-capabilities", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ capabilities: ["ai_patrol", "ai_autofix"], limits: [], hosted_mode: false, max_history_days: 365, runtime: { build: "enterprise", label: "Pulse Pro runtime" }, blocked_capabilities: [] }) }));
await page.route("**/api/ai/patrol/autonomy/acknowledgements", async (route) => {
const body = route.request().postDataJSON();
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ created: true, acknowledgement: { ...requiredStatus, code: "active", active: true, acknowledgementVersion: 1, acknowledgementId: body.acknowledgement_id, acceptedAt: "2026-07-12T00:00:00Z" } }) });
});
await page.route("**/api/ai/patrol/autonomy/acknowledgements/*", async (route) => {
active = false;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ revoked: true, created: true, acknowledgement_id: "ack-1" }) });
});
await page.route("**/api/ai/patrol/autonomy", async (route) => {
if (route.request().method() === "PUT") { active = route.request().postDataJSON().autonomy_level === "full"; }
const status = active ? { ...requiredStatus, code: "active", active: true, acknowledgementVersion: 1, acknowledgementId: "ack-1", acceptedAt: "2026-07-12T00:00:00Z" } : requiredStatus;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, settings: { autonomy_level: active ? "full" : "monitor", requested_autonomy_level: active ? "full" : "monitor", effective_autonomy_level: active ? "full" : "monitor", full_mode_unlocked: active, autopilot_acknowledgement: status, investigation_budget: 15, investigation_timeout_sec: 300 }, autonomy_level: active ? "full" : "monitor", requested_autonomy_level: active ? "full" : "monitor", effective_autonomy_level: active ? "full" : "monitor", full_mode_unlocked: active, autopilot_acknowledgement: status, investigation_budget: 15, investigation_timeout_sec: 300 }) });
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Autopilot" }).click();
await expect(page.getByRole("dialog", { name: "Activate Autopilot" })).toBeVisible();
const activate = page.getByRole("button", { name: "Record acknowledgement and activate" });
await expect(activate).toBeDisabled();
await testInfo.attach("autopilot-versioned-acknowledgement", { body: await page.screenshot(), contentType: "image/png" });
await page.getByRole("checkbox").check();
const acknowledgementRequest = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/ai/patrol/autonomy/acknowledgements"));
const activationRequest = page.waitForRequest((request) => request.method() === "PUT" && request.url().endsWith("/api/ai/patrol/autonomy"));
await activate.click();
const [acknowledgement, activation] = await Promise.all([acknowledgementRequest, activationRequest]);
expect(acknowledgement.postDataJSON().acknowledgement_id).toBeTruthy();
expect(activation.postDataJSON()).toMatchObject({ autonomy_level: "full", acknowledgement_id: expect.any(String) });
await expect(page.getByText("Autopilot acknowledgement v1")).toBeVisible();
await testInfo.attach("autopilot-effective-full-mode", { body: await page.screenshot(), contentType: "image/png" });
const revocationRequest = page.waitForRequest((request) => request.method() === "DELETE" && request.url().includes("/api/ai/patrol/autonomy/acknowledgements/"));
await page.getByRole("button", { name: "Revoke Autopilot" }).click();
await revocationRequest;
});
@@ -0,0 +1,31 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = { authStorageStatePath: string };
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => use(authStorageStatePath),
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(__dirname, "..", "..", "tmp", "playwright-auth", `product-trust-a11y-${workerInfo.project.name}.json`);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try { await use(storageStatePath); } finally { fs.rmSync(storageStatePath, { force: true }); }
}, { scope: "worker" }],
});
test("Actions remains named, keyboard reachable, and free of horizontal overflow at phone width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/actions?*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ view: "pending", actions: [], count: 0 }) }));
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible();
await expect(page.getByRole("tablist", { name: "Action views" })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh actions" })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
expect(overflow).toBeFalsy();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await testInfo.attach("actions-phone-width", { body: await page.screenshot(), contentType: "image/png" });
});