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