diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 714d7bc02..20d004aff 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 75b11fec6..c1d7a705f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index cf52bd195..0891670e6 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 232bc5e7d..44eee57ff 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 194e2fe9e..c0e5eaee6 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 36f6a57ba..05fb027ab 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 4e989e918..965cd31b7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ef8f67fd8..6838f97c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 35ad73b38..973691080 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 7780c316d..d53eb5992 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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() { + diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 9d8b25d59..99d01f1a5 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -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', diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 5b8c1ab09..0961051d0 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -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(`[data-tab-id="${tabId}"]`); expect(button).toBeTruthy(); expect(button?.querySelector('svg')).toBeTruthy(); diff --git a/frontend-modern/src/api/__tests__/actionAudit.test.ts b/frontend-modern/src/api/__tests__/actionAudit.test.ts index 969051692..a1db1c0d6 100644 --- a/frontend-modern/src/api/__tests__/actionAudit.test.ts +++ b/frontend-modern/src/api/__tests__/actionAudit.test.ts @@ -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: [ diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index 4f0044772..9c371d7a2 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -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'); diff --git a/frontend-modern/src/api/__tests__/resourceActions.test.ts b/frontend-modern/src/api/__tests__/resourceActions.test.ts new file mode 100644 index 000000000..bafad3bd9 --- /dev/null +++ b/frontend-modern/src/api/__tests__/resourceActions.test.ts @@ -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'); + }); +}); diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index b8f5fece7..f56870ac0 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -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 { 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 */ diff --git a/frontend-modern/src/api/resourceActions.ts b/frontend-modern/src/api/resourceActions.ts index 7db0f9018..05677e7e9 100644 --- a/frontend-modern/src/api/resourceActions.ts +++ b/frontend-modern/src/api/resourceActions.ts @@ -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 { + const params = new URLSearchParams({ view, limit: String(limit) }); + return apiFetchJSON(`/api/actions?${params.toString()}`); + } + + static async getAction(actionId: string): Promise { + return apiFetchJSON(`/api/actions/${encodeURIComponent(actionId)}`); + } + static async listPendingActions(): Promise { return apiFetchJSON('/api/actions/pending'); } diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index ca1343062..0bd7f3022 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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( diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 228605126..c7ab28164 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -5301,7 +5301,7 @@ export const AIChat: Component = (props) => { - {controlPresentation().label} + Chat: {controlPresentation().label} = (props) => { : 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300'; return (
-
{v.success ? 'Verified' : 'Verification failed'}
+
+ {v.success ? 'Legacy check passed (source unclassified)' : 'Legacy check failed (source unclassified)'} +
{v.command}
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceActionHistory.verification.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceActionHistory.verification.test.ts index 12274d9a3..2df9f87d2 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceActionHistory.verification.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceActionHistory.verification.test.ts @@ -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(); diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index bfe532b27..96a3a3425 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -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': diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index d7c848828..02fc2377a 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -104,7 +104,7 @@ function audit( investigationId: 'investigation-1', proposalId: 'proposal-1', }, - verificationOutcome: { status: verification }, + verificationOutcome: { status: verification as never }, }; } diff --git a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx index db7e4694a..ccacb2f1f 100644 --- a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx @@ -17,6 +17,7 @@ const StandaloneIcon: Component<{ class?: string }> = (props) => ( ); const ProxmoxIcon: Component<{ class?: string }> = (props) => PX; const AlertsIcon: Component<{ class?: string }> = (props) => AL; +const ActionsIcon: Component<{ class?: string }> = (props) => AC; const SettingsIcon: Component<{ class?: string }> = (props) => SE; const PatrolIcon: Component<{ class?: string }> = (props) => ( @@ -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(() => ( + '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(() => ( ( tabs: T[], diff --git a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx new file mode 100644 index 000000000..a0fdffde9 --- /dev/null +++ b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx @@ -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 ( +
+
+

What will happen

+
+
Action
{formatActionName(props.audit.request.capabilityName)}
+
Resource
{props.audit.request.resourceId}
+
Reason
{props.audit.request.reason}
+
Current state
{props.audit.plan.preflight?.currentState}
+
Intended change
{props.audit.plan.preflight?.intendedChange}
+
Approval expires
{expiry()}
+
Rollback declared
{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}
+
+ 0}> +
Also affected
    {(resource) =>
  • {resource}
  • }
+
+
+ +
+

Why Pulse allows this review

+ This older action has no server-recorded policy provenance. Re-plan it before acting.

}> +

Server decision {policy()?.decisionId}

+
+ + {(authority) => ( +
+
+ {formatPolicyAuthority(authority)} + {authority.status === 'consulted' ? 'Consulted' : formatActionName(authority.status)} +
+
{authority.sourceId} · {authority.revision}
+
    {(reason) =>
  • {formatPolicyReason(reason)}
  • }
+
+ )} +
+
+

This records planning-time policy evidence. Pulse checks current authority again before execution.

+
+
+ + + {(truth) => ( +
+

Recorded outcome

+
+
Execution
+
{truth().execution.status === 'not_run' ? 'Did not run' : formatActionName(truth().execution.status)}
+
Reason: {formatActionName(truth().execution.reasonCode!)}
+

{truth().execution.summary}

+
+
+
Verification
+
{verificationTruthLabel(truth().verification.status, truth().verification.evidenceClass)}
+
Source: {formatEvidenceClass(truth().verification.evidenceClass)}
+
Reason: {formatActionName(truth().verification.reasonCode!)}
+

{truth().verification.summary}

+ 0}> +
Evidence details
    {(evidence) =>
  • {evidence.summary || evidence.method}
    Observed by {evidence.observerId} · {evidence.observerTrustDomain}
  • }
+
+
+
+
Recovery
+
{formatActionName(truth().compensation.status)}
+
Support: {formatActionName(truth().compensation.support)}
+
Strategy: {truth().compensation.strategy}
+

{truth().compensation.summary}

+
+
+ )} +
+
+ ); +}; diff --git a/frontend-modern/src/features/actions/ActionReviewDialog.tsx b/frontend-modern/src/features/actions/ActionReviewDialog.tsx new file mode 100644 index 000000000..e6e8da997 --- /dev/null +++ b/frontend-modern/src/features/actions/ActionReviewDialog.tsx @@ -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; +}> = (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 ( + + + {(record) => ( +
+
+

Governed action review

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

{record().request.resourceId}

+ +
+
+
+ + + +
+
+ )} +
+
+ ); +}; diff --git a/frontend-modern/src/features/actions/__tests__/ActionDecisionPacket.test.tsx b/frontend-modern/src/features/actions/__tests__/ActionDecisionPacket.test.tsx new file mode 100644 index 000000000..6561b2f49 --- /dev/null +++ b/frontend-modern/src/features/actions/__tests__/ActionDecisionPacket.test.tsx @@ -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(() => ); + 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(() => ); + 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(); + }); +}); diff --git a/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx b/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx new file mode 100644 index 000000000..32b43971b --- /dev/null +++ b/frontend-modern/src/features/actions/__tests__/ActionReviewDialog.test.tsx @@ -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(() => ); + 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(() => ); + 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'); + }); +}); diff --git a/frontend-modern/src/features/actions/actionPresentation.ts b/frontend-modern/src/features/actions/actionPresentation.ts new file mode 100644 index 000000000..c06cdcde7 --- /dev/null +++ b/frontend-modern/src/features/actions/actionPresentation.ts @@ -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 = { + 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'; + } +}; diff --git a/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx b/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx index b4894c7bb..7ba1478ff 100644 --- a/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx +++ b/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx @@ -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 = ( - props, -) => { - const [confirmingAction, setConfirmingAction] = - createSignal(null); - const [runningAction, setRunningAction] = createSignal( - null, - ); - const [completedAction, setCompletedAction] = createSignal( - null, - ); +export const DockerContainerLifecycleControls: Component = (props) => { + const [planningAction, setPlanningAction] = createSignal(null); + const [reviewAction, setReviewAction] = createSignal(null); + const [reviewDetail, setReviewDetail] = createSignal(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 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 { - 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 ( -
+
{(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 ( - ); }} + 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.'); + } + }} + />
); }; diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index ae4720a40..d231d95c8 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -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', () => { diff --git a/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx b/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx new file mode 100644 index 000000000..d6099aafb --- /dev/null +++ b/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx @@ -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 ( + +
+
+

Explicit control change

Activate Autopilot

Pulse will record a server-owned version {status()?.currentVersion ?? 'current'} acknowledgement before requesting full mode.

+ +
+
+
+

Autopilot may execute eligible infrastructure actions without asking each time.

+

It remains bounded by approval floors, per-resource policy, the emergency stop, current licensing, and fresh execution-time authorization.

+
+

What this acknowledgement covers

+
    {(scope) =>
  • {scopeLabel(scope)}
  • }
+

Truth and recovery limits

+
    +
  • Verification can be confirmed, contradicted, inconclusive, or not attempted.
  • +
  • Pulse discloses whether evidence came from the executing agent or an independent observer.
  • +
  • You can revoke this acknowledgement. Version changes, expiry, revocation, policy drift, or reconnect races demote effective mode server-side.
  • +
+ +
+
+
+
+ ); +}; diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index 0a0ddcbb4..a678a62b5 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -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
+ +
+ Requested {PATROL_AUTONOMY_POLICY_PRESENTATION[state.requestedAutonomyLevel()].label}; effective mode is {selectedAutonomyPolicy().label}. Server status: {state.autopilotStatus()?.code.replace(/_/g, ' ') || 'unavailable'}. +
+
+ +
+
Autopilot acknowledgement v{state.autopilotStatus()?.acknowledgementVersion}Active for this identity until {new Date(state.autopilotStatus()!.expiresAt!).toLocaleString()}.
+ +
+
); return (
+ { + 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(() => ); + 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); + }); +}); diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 67808e63e..d7b1a4996 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -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('monitor'); + const [requestedAutonomyLevel, setRequestedAutonomyLevel] = createSignal('monitor'); + const [autopilotStatus, setAutopilotStatus] = createSignal(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 | undefined) => { findingScrollTimerRef = timer; diff --git a/frontend-modern/src/pages/Actions.tsx b/frontend-modern/src/pages/Actions.tsx new file mode 100644 index 000000000..9d5f801bb --- /dev/null +++ b/frontend-modern/src/pages/Actions.tsx @@ -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('pending'); + const [selected, setSelected] = createSignal(null); + const [detailError, setDetailError] = createSignal(''); + const [actions, setActions] = createSignal([]); + 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 ( +
+ +
+
+ + +
+ +
+ + + + +

{view() === 'pending' ? 'No actions need attention' : 'No action history yet'}

{view() === 'pending' ? 'Pulse will place proposed or in-progress changes here for review.' : 'Completed, rejected, expired, and failed actions will appear here.'}

+
+ 0}> +
    + {(action) =>
  • }
    +
+
+ setSelected(null)} onChanged={async (detail) => { setSelected(detail); await loadActions(); }} /> +
+ ); +} + +export default Actions; diff --git a/frontend-modern/src/routing/__tests__/navigation.test.ts b/frontend-modern/src/routing/__tests__/navigation.test.ts index e6c4cd66e..3588b59dd 100644 --- a/frontend-modern/src/routing/__tests__/navigation.test.ts +++ b/frontend-modern/src/routing/__tests__/navigation.test.ts @@ -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(); diff --git a/frontend-modern/src/routing/__tests__/routePreload.test.ts b/frontend-modern/src/routing/__tests__/routePreload.test.ts index b05d7d3f9..e647c857c 100644 --- a/frontend-modern/src/routing/__tests__/routePreload.test.ts +++ b/frontend-modern/src/routing/__tests__/routePreload.test.ts @@ -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', ]); diff --git a/frontend-modern/src/routing/navigation.ts b/frontend-modern/src/routing/navigation.ts index e1fe2a242..a371e32bd 100644 --- a/frontend-modern/src/routing/navigation.ts +++ b/frontend-modern/src/routing/navigation.ts @@ -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; diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 008a482c8..f44abe6ba 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -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'; diff --git a/frontend-modern/src/routing/routePreload.ts b/frontend-modern/src/routing/routePreload.ts index e2bd72df2..350ecc1b6 100644 --- a/frontend-modern/src/routing/routePreload.ts +++ b/frontend-modern/src/routing/routePreload.ts @@ -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}/`), diff --git a/frontend-modern/src/types/actionAudit.ts b/frontend-modern/src/types/actionAudit.ts index 9dd4b2e48..5baefa847 100644 --- a/frontend-modern/src/types/actionAudit.ts +++ b/frontend-modern/src/types/actionAudit.ts @@ -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; reason: string; requestedBy: string; + actor?: ActionActor; } -export type ResourceActionRequest = ActionAuditRequest; +export type ResourceActionRequest = Omit; + +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; diff --git a/frontend-modern/src/utils/__tests__/actionAuditPresentation.test.ts b/frontend-modern/src/utils/__tests__/actionAuditPresentation.test.ts index 551ae9978..ea39a32fd 100644 --- a/frontend-modern/src/utils/__tests__/actionAuditPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/actionAuditPresentation.test.ts @@ -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' }); diff --git a/frontend-modern/src/utils/__tests__/apiClient.org.test.ts b/frontend-modern/src/utils/__tests__/apiClient.org.test.ts index d0dfedfb7..52a9eccc1 100644 --- a/frontend-modern/src/utils/__tests__/apiClient.org.test.ts +++ b/frontend-modern/src/utils/__tests__/apiClient.org.test.ts @@ -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; + 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( diff --git a/frontend-modern/src/utils/actionAuditPresentation.ts b/frontend-modern/src/utils/actionAuditPresentation.ts index 8cef7a57e..572a281a3 100644 --- a/frontend-modern/src/utils/actionAuditPresentation.ts +++ b/frontend-modern/src/utils/actionAuditPresentation.ts @@ -2,7 +2,6 @@ import type { ActionAuditRefusalPrefix, ActionAuditRecord, ActionAuditState, - ActionVerificationOutcome, ActionVerificationResult, } from '@/types/actionAudit'; @@ -55,6 +54,11 @@ const ACTION_STATE_PRESENTATION: Record, + 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, -): boolean => getActionAuditVerification(audit)?.ran === true; +): boolean => + audit.result?.actionResultV2 === undefined && getActionAuditVerification(audit)?.ran === true; diff --git a/frontend-modern/src/utils/assistantPageContext.ts b/frontend-modern/src/utils/assistantPageContext.ts index 6f36cdd0e..525697cbe 100644 --- a/frontend-modern/src/utils/assistantPageContext.ts +++ b/frontend-modern/src/utils/assistantPageContext.ts @@ -12,6 +12,7 @@ export interface AssistantPageContextPresentation { const VIEW_LABELS: Record, string> = { alerts: 'Alerts', + actions: 'Actions', ai: 'Patrol', docker: 'Docker', kubernetes: 'Kubernetes', diff --git a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts index 1f2dd424c..b3a2d7078 100644 --- a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts +++ b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts @@ -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, }), diff --git a/tests/integration/tests/81-actions-inbox.spec.ts b/tests/integration/tests/81-actions-inbox.spec.ts new file mode 100644 index 000000000..f5749234d --- /dev/null +++ b/tests/integration/tests/81-actions-inbox.spec.ts @@ -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" }); +}); diff --git a/tests/integration/tests/82-autopilot-acknowledgement.spec.ts b/tests/integration/tests/82-autopilot-acknowledgement.spec.ts new file mode 100644 index 000000000..c753514b2 --- /dev/null +++ b/tests/integration/tests/82-autopilot-acknowledgement.spec.ts @@ -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; +}); diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts new file mode 100644 index 000000000..ac72fbb09 --- /dev/null +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -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" }); +});